feat: convert ssh tunnels into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 17:27:28 -05:00
1 parent c85c1fdb86
commit 1682861853
220 files changed
+36928 -11374

No files matched your search

+14 -4
View File
@@ -63,6 +63,11 @@ export interface ConnectHostOptions {
/** Use this client instead of a new one. */
client?: Client;
transport?: OpenTransportOptions;
/**
* An already-open stream to the host (a forwardOut channel through another
* host). Skips openSshTransport entirely.
*/
sock?: MutableConnectConfig["sock"];
log?: SshAuthLog;
}
@@ -105,10 +110,15 @@ export async function connectHost(
throw new SshConnectError(outcome);
}
const { jumpClient } = await openSshTransport(host, config, {
log: options.log,
...options.transport,
});
let jumpClient: Client | null = null;
if (options.sock) {
config.sock = options.sock;
} else {
({ jumpClient } = await openSshTransport(host, config, {
log: options.log,
...options.transport,
}));
}
const keyboardInteractive =
options.keyboardInteractive ??
-106
View File
@@ -1,106 +0,0 @@
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type { ClientChannel } from "ssh2";
import type { WebSocket } from "ws";
import { extractWebSocketToken } from "../../utils/ws-auth.js";
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
export function extractRequestToken(req: IncomingMessage): string | undefined {
return extractWebSocketToken(req);
}
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 lowerMessage = message.toLowerCase();
if (
lowerMessage.includes("administratively prohibited") ||
lowerMessage.includes("forwarding disabled") ||
lowerMessage.includes("open failed")
) {
return `SSH forwarding was rejected by the endpoint server: ${message}`;
}
if (
lowerMessage.includes("address already in use") ||
lowerMessage.includes("unable to bind") ||
lowerMessage.includes("bind")
) {
return `Remote port is not available on the endpoint server: ${message}`;
}
if (
lowerMessage.includes("name or service not known") ||
lowerMessage.includes("enotfound") ||
lowerMessage.includes("econnrefused")
) {
return `Tunnel target is not reachable from the endpoint host: ${message}`;
}
return message || "Failed to open relay";
}
function pauseSourceForC2SWebSocket(ws: WebSocket, source?: Duplex): void {
if (!source) return;
if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return;
source.pause();
const resumeTimer = setInterval(() => {
if (
ws.readyState !== 1 ||
source.destroyed ||
ws.bufferedAmount <= C2S_WS_LOW_WATERMARK
) {
clearInterval(resumeTimer);
if (ws.readyState === 1 && !source.destroyed) {
source.resume();
}
}
}, 25);
}
export function sendC2SMessage(
ws: WebSocket,
message: Record<string, unknown>,
source?: Duplex,
): void {
if (ws.readyState === 1) {
ws.send(JSON.stringify(message), (error) => {
if (error && source && !source.destroyed) {
source.destroy(error);
}
});
pauseSourceForC2SWebSocket(ws, source);
}
}
export function writeC2SRemoteChunk(
target: ClientChannel,
chunk: Buffer,
ws: WebSocket,
closeTarget: () => void,
): void {
if (!target || target.destroyed) return;
if (target.writableLength > C2S_STREAM_WRITE_LIMIT) {
closeTarget();
return;
}
const canContinue = target.write(chunk);
if (!canContinue) {
ws.pause();
target.once("drain", () => {
if (ws.readyState === 1) {
ws.resume();
}
});
}
}
-464
View File
@@ -1,464 +0,0 @@
import { Client, type ClientChannel } from "ssh2";
import type { WebSocket } from "ws";
import type { TunnelConfig } from "../../../types/index.js";
import { createSocks5Connection } from "../../utils/socks5-helper.js";
import { tunnelLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
applyTunnelAuth,
bindForwardIn,
connectClient,
forwardOut,
getManagedTunnelAlgorithms,
unbindForwardIn,
} from "./ssh-primitives.js";
import { sendC2SMessage, writeC2SRemoteChunk } from "./c2s-relay-utils.js";
import { getTunnelMode } from "./utils.js";
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
export type C2SOpenMessage = {
type: "open" | "test";
tunnelConfig?: Partial<TunnelConfig>;
targetHost?: string;
targetPort?: number;
};
const permissionManager = PermissionManager.getInstance();
let c2sRemoteStreamCounter = 0;
function normalizeAddress(
value: unknown,
fallback?: string,
): string | undefined {
const address = typeof value === "string" ? value.trim() : "";
return address || fallback;
}
export async function resolveC2SSourceHostId(
tunnelConfig: Partial<TunnelConfig>,
findHostIdBySyncId: (syncId: string) => Promise<number | null>,
): Promise<number> {
const sourceHostSyncId = tunnelConfig.sourceHostSyncId?.trim();
if (sourceHostSyncId) {
const remoteHostId = await findHostIdBySyncId(sourceHostSyncId);
if (!remoteHostId) {
throw new Error("Endpoint SSH host was not found on the remote server");
}
return remoteHostId;
}
if (!tunnelConfig.sourceHostId) {
throw new Error("Endpoint SSH host is required");
}
return tunnelConfig.sourceHostId;
}
async function resolveC2STunnelSource(
tunnelConfig: Partial<TunnelConfig>,
userId: string,
): Promise<TunnelConfig> {
const sourceHostId = await resolveC2SSourceHostId(tunnelConfig, (syncId) =>
createCurrentHostResolutionRepository().findHostIdBySyncId(syncId),
);
const accessInfo = await permissionManager.canAccessHost(
userId,
sourceHostId,
"connect",
);
if (!accessInfo.hasAccess) {
throw new Error("Access denied to this host");
}
const { resolveHostById } = await import("../host-resolver.js");
const resolvedHost = await resolveHostById(sourceHostId, userId);
if (!resolvedHost) {
throw new Error("Endpoint SSH host not found");
}
const localAddress = normalizeAddress(
tunnelConfig.localAddress,
tunnelConfig.bindHost,
);
const remoteAddress = normalizeAddress(
tunnelConfig.remoteAddress,
tunnelConfig.targetHost || "127.0.0.1",
);
return {
name: tunnelConfig.name || `c2s:${sourceHostId}`,
scope: "c2s",
mode: tunnelConfig.mode || "local",
tunnelType:
tunnelConfig.tunnelType ||
(tunnelConfig.mode === "remote" ? "remote" : "local"),
localAddress,
remoteAddress,
bindHost: localAddress,
targetHost: remoteAddress,
sourceHostId: resolvedHost.id || sourceHostId,
sourceHostSyncId: tunnelConfig.sourceHostSyncId,
tunnelIndex: tunnelConfig.tunnelIndex || 0,
requestingUserId: userId,
hostName:
resolvedHost.name || `${resolvedHost.username}@${resolvedHost.ip}`,
sourceIP: resolvedHost.ip,
sourceSSHPort: resolvedHost.port,
sourceUsername: resolvedHost.username,
sourcePassword: resolvedHost.password,
sourceAuthMethod: resolvedHost.authType,
sourceSSHKey: resolvedHost.key,
sourceKeyPassword: resolvedHost.keyPassword,
sourceKeyType: resolvedHost.keyType,
sourceCredentialId: resolvedHost.credentialId,
sourceUserId: resolvedHost.userId,
endpointIP: tunnelConfig.endpointIP || resolvedHost.ip,
endpointSSHPort: tunnelConfig.endpointSSHPort || resolvedHost.port,
endpointUsername: resolvedHost.username,
endpointHost:
tunnelConfig.endpointHost || resolvedHost.name || resolvedHost.ip,
endpointAuthMethod: resolvedHost.authType,
endpointSSHKey: resolvedHost.key,
endpointKeyPassword: resolvedHost.keyPassword,
endpointKeyType: resolvedHost.keyType,
endpointCredentialId: resolvedHost.credentialId,
endpointUserId: resolvedHost.userId,
sourcePort: Number(tunnelConfig.sourcePort) || 0,
endpointPort: Number(tunnelConfig.endpointPort) || 0,
maxRetries: Number(tunnelConfig.maxRetries) || 0,
retryInterval: Number(tunnelConfig.retryInterval) || 0,
autoStart: Boolean(tunnelConfig.autoStart),
isPinned: Boolean(resolvedHost.pin),
useSocks5: Boolean(resolvedHost.useSocks5),
socks5Host: resolvedHost.socks5Host,
socks5Port: resolvedHost.socks5Port,
socks5Username: resolvedHost.socks5Username,
socks5Password: resolvedHost.socks5Password,
socks5ProxyChain: resolvedHost.socks5ProxyChain,
keepaliveInterval:
typeof resolvedHost.terminalConfig?.keepaliveInterval === "number"
? resolvedHost.terminalConfig.keepaliveInterval * 1000
: 60000,
keepaliveCountMax:
typeof resolvedHost.terminalConfig?.keepaliveCountMax === "number"
? resolvedHost.terminalConfig.keepaliveCountMax
: 5,
};
}
async function connectC2SSourceClient(
tunnelConfig: TunnelConfig,
): Promise<Client> {
const connOptions: Record<string, unknown> = {
host:
tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
tryKeyboard: true,
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 30000,
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 3,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: getManagedTunnelAlgorithms(),
};
const client = new Client();
const authOutcome = await applyTunnelAuth(
connOptions,
{
password: tunnelConfig.sourcePassword,
sshKey: tunnelConfig.sourceSSHKey,
keyPassword: tunnelConfig.sourceKeyPassword,
keyType: tunnelConfig.sourceKeyType,
authMethod: tunnelConfig.sourceAuthMethod,
},
{
client,
userId: tunnelConfig.requestingUserId || tunnelConfig.sourceUserId,
hostId: tunnelConfig.sourceHostId,
username: tunnelConfig.sourceUsername,
ip: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
},
);
if (authOutcome.status !== "ready") {
throw new Error(authOutcome.message);
}
if (
tunnelConfig.useSocks5 &&
(tunnelConfig.socks5Host ||
(tunnelConfig.socks5ProxyChain &&
tunnelConfig.socks5ProxyChain.length > 0))
) {
const socks5Socket = await createSocks5Connection(
tunnelConfig.sourceIP,
tunnelConfig.sourceSSHPort,
{
useSocks5: tunnelConfig.useSocks5,
socks5Host: tunnelConfig.socks5Host,
socks5Port: tunnelConfig.socks5Port,
socks5Username: tunnelConfig.socks5Username,
socks5Password: tunnelConfig.socks5Password,
socks5ProxyChain: tunnelConfig.socks5ProxyChain,
},
);
if (socks5Socket) {
connOptions.sock = socks5Socket;
}
}
return connectClient(connOptions, tunnelConfig.name, "source", client);
}
async function handleC2SRemoteRelayOpen(
ws: WebSocket,
tunnelConfig: TunnelConfig,
): Promise<void> {
const tunnelName = tunnelConfig.name;
const sourceClient = await connectC2SSourceClient(tunnelConfig);
const bindHost =
tunnelConfig.remoteAddress || tunnelConfig.targetHost || "127.0.0.1";
const bindPort = Number(tunnelConfig.sourcePort);
let closed = false;
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
throw new Error("Invalid remote port");
}
const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
const streams = new Map<string, ClientChannel>();
const closeStream = (streamId: string): void => {
const stream = streams.get(streamId);
if (!stream) return;
streams.delete(streamId);
try {
stream.destroy();
} catch {
// expected during shutdown
}
};
const close = (): void => {
if (closed) return;
closed = true;
for (const streamId of streams.keys()) {
closeStream(streamId);
}
unbindForwardIn(sourceClient, bindHost, actualPort);
try {
sourceClient.end();
} catch {
// expected during shutdown
}
};
sourceClient.on("tcp connection", (info, accept, reject) => {
if (info.destPort !== actualPort) {
reject();
return;
}
const inbound = accept();
const streamId = `${Date.now()}-${++c2sRemoteStreamCounter}`;
streams.set(streamId, inbound);
sendC2SMessage(ws, { type: "connection", streamId });
inbound.on("data", (chunk) => {
sendC2SMessage(
ws,
{
type: "data",
streamId,
data: chunk.toString("base64"),
},
inbound,
);
});
inbound.on("close", () => {
streams.delete(streamId);
sendC2SMessage(ws, { type: "close", streamId });
});
inbound.on("error", (error) => {
streams.delete(streamId);
sendC2SMessage(ws, {
type: "close",
streamId,
error: error instanceof Error ? error.message : String(error),
});
});
});
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;
if (message.type === "data" && message.data) {
const stream = streams.get(message.streamId);
if (stream) {
writeC2SRemoteChunk(
stream,
Buffer.from(message.data, "base64"),
ws,
() => closeStream(message.streamId as string),
);
}
} else if (message.type === "close") {
closeStream(message.streamId);
}
} catch (error) {
tunnelLogger.warn("Invalid C2S remote relay message", {
operation: "c2s_remote_relay_invalid_message",
tunnelName,
error: 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) => {
sendC2SMessage(ws, {
type: "error",
error: error instanceof Error ? error.message : String(error),
});
if (ws.readyState === 1) ws.close();
});
tunnelLogger.info("C2S remote tunnel ready", {
operation: "c2s_remote_tunnel_ready",
tunnelName,
bindHost,
bindPort: actualPort,
endpointHost: tunnelConfig.endpointHost,
});
sendC2SMessage(ws, { type: "ready", bindHost, bindPort: actualPort });
}
export async function handleC2SRelayOpen(
ws: WebSocket,
message: C2SOpenMessage,
userId: string,
): Promise<void> {
const tunnelConfig = await resolveC2STunnelSource(
message.tunnelConfig || {},
userId,
);
const mode = getTunnelMode(tunnelConfig);
if (mode === "remote") {
await handleC2SRemoteRelayOpen(ws, tunnelConfig);
return;
}
const targetHost =
mode === "dynamic"
? message.targetHost
: tunnelConfig.remoteAddress || tunnelConfig.targetHost || "127.0.0.1";
const targetPort =
mode === "dynamic"
? Number(message.targetPort)
: Number(tunnelConfig.endpointPort);
if (!targetHost || !Number.isInteger(targetPort) || targetPort < 1) {
throw new Error("Invalid client tunnel target");
}
const sourceClient = await connectC2SSourceClient(tunnelConfig);
const outbound = await forwardOut(sourceClient, targetHost, targetPort);
const close = () => {
try {
outbound.destroy();
} catch {
// expected during shutdown
}
try {
sourceClient.end();
} catch {
// expected during shutdown
}
};
outbound.on("data", (chunk) => {
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);
outbound.write(chunk);
});
ws.send(JSON.stringify({ type: "ready" }));
}
export async function handleC2SRelayTest(
ws: WebSocket,
message: C2SOpenMessage,
userId: string,
): Promise<void> {
const tunnelConfig = await resolveC2STunnelSource(
message.tunnelConfig || {},
userId,
);
const mode = getTunnelMode(tunnelConfig);
const sourceClient = await connectC2SSourceClient(tunnelConfig);
try {
if (mode === "remote") {
const bindHost =
tunnelConfig.remoteAddress || tunnelConfig.targetHost || "127.0.0.1";
const bindPort = Number(tunnelConfig.sourcePort);
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
throw new Error("Invalid remote port");
}
const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
unbindForwardIn(sourceClient, bindHost, actualPort);
} else if (mode === "local") {
const targetHost =
tunnelConfig.remoteAddress || tunnelConfig.targetHost || "127.0.0.1";
const targetPort = Number(tunnelConfig.endpointPort);
if (!Number.isInteger(targetPort) || targetPort < 1) {
throw new Error("Invalid remote target port");
}
const outbound = await forwardOut(sourceClient, targetHost, targetPort);
outbound.destroy();
}
sendC2SMessage(ws, { type: "ready" });
} finally {
try {
sourceClient.end();
} catch {
// expected during shutdown
}
}
}
-110
View File
@@ -1,110 +0,0 @@
import express from "express";
import { createServer } from "http";
import { createCorsMiddleware } from "../../utils/cors-config.js";
import { createCompressionMiddleware } from "../../utils/compression-config.js";
import cookieParser from "cookie-parser";
import { WebSocketServer } from "ws";
import { tunnelLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import {
describeC2SRelayError,
extractRequestToken,
sendC2SError,
} from "./c2s-relay-utils.js";
import {
handleC2SRelayOpen,
handleC2SRelayTest,
type C2SOpenMessage,
} from "./c2s-relay.js";
import { registerTunnelRoutes } from "./routes.js";
import { initializeAutoStartTunnels } from "./manager.js";
import { attachServicePortConflictHandler } from "../../utils/service-listen.js";
const authManager = AuthManager.getInstance();
const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
app.use(express.json());
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
registerTunnelRoutes(app);
const PORT = 30003;
const server = createServer(app);
const c2sRelayWss = new WebSocketServer({
server,
path: "/ssh/tunnel/c2s/stream",
});
c2sRelayWss.on("error", (error) => {
tunnelLogger.error("C2S relay WebSocket server error", error, {
operation: "c2s_relay_wss_error",
});
});
c2sRelayWss.on("connection", (ws, req) => {
let opened = false;
ws.on("error", (error) => {
tunnelLogger.error("C2S relay WebSocket connection error", error, {
operation: "c2s_relay_ws_error",
});
});
ws.once("message", async (raw) => {
try {
const token = extractRequestToken(req);
const payload = token ? await authManager.verifyJWTToken(token) : null;
if (!payload?.userId || payload.pendingTOTP) {
sendC2SError(ws, "Authentication required");
ws.close();
return;
}
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 handleC2SRelayTest(ws, message, payload.userId);
} else {
await handleC2SRelayOpen(ws, message, payload.userId);
}
} catch (error) {
const message = describeC2SRelayError(error);
tunnelLogger.error("Failed to open C2S relay", error, {
operation: "c2s_relay_open_failed",
});
sendC2SError(ws, message);
ws.close();
}
});
ws.on("close", () => {
if (!opened) {
tunnelLogger.info("C2S relay closed before opening", {
operation: "c2s_relay_closed_before_open",
});
}
});
});
attachServicePortConflictHandler(server, PORT, tunnelLogger, "tunnel");
server.listen(PORT, "127.0.0.1", () => {
setTimeout(() => {
initializeAutoStartTunnels();
}, 2000);
});
File diff suppressed because it is too large. Load diff
-680
View File
@@ -1,680 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express, { type Response } from "express";
import axios from "axios";
import type {
SSHHost,
TunnelConfig,
AuthenticatedRequest,
} from "../../../types/index.js";
import { CONNECTION_STATES } from "../../../types/index.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { tunnelLogger } from "../../utils/logger.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
RESERVED_TUNNEL_NAME_PREFIX,
getTunnelMode,
isReservedTunnelName,
parseReservedTunnelName,
validateTunnelConfig,
} from "./utils.js";
import {
tunnelConfigs,
activeRetryTimers,
pendingTunnelOperations,
manualDisconnects,
retryExhaustedTunnels,
retryCounters,
countdownIntervals,
tunnelConnecting,
tunnelStatusClients,
connectionStatus,
cleanupTunnelResources,
broadcastTunnelStatus,
handleDisconnect,
sendTunnelStatusSnapshot,
isSingleHostTunnel,
getTunnelStatusForUser,
canAccessTunnel,
findHostByTunnelEndpoint,
connectSSHTunnel,
} from "./manager.js";
const permissionManager = PermissionManager.getInstance();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* Ownership check for an action addressed by tunnel NAME alone.
*
* The existing check only ran inside `if (config && config.sourceHostId)`, and
* web endpoint tunnels are deliberately kept out of tunnelConfigs -- so
* `config` is always undefined for them and the check never ran at all. Host
* ids are small sequential integers and endpoint ids are client-supplied with
* no server-side randomness, so anyone could guess `web:{hostId}:{endpointId}`
* and force-close another user's live tunnel; manualDisconnects then makes the
* victim's own re-open fail for several seconds.
*
* A reserved name whose host id cannot be parsed FAILS CLOSED rather than
* falling through to the unchecked path.
*/
export async function authorizeTunnelAction(
userId: string,
tunnelName: string,
config: { sourceHostId?: number } | undefined,
): Promise<{ allowed: boolean; hostId?: number }> {
const hostId =
config?.sourceHostId ?? parseReservedTunnelName(tunnelName)?.hostId;
if (hostId === undefined) {
// Not a reserved name and not a registered config: nothing to check
// against. Preserves the pre-existing behaviour for ordinary names;
// tightening that is a separate change with its own blast radius.
return { allowed: !isReservedTunnelName(tunnelName) };
}
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
"connect",
);
return { allowed: accessInfo.hasAccess, hostId };
}
export function registerTunnelRoutes(app: express.Express): void {
app.get(
"/ssh/tunnel/status",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
if (!req.userId) {
return res.status(401).json({ error: "Authentication required" });
}
res.json(await getTunnelStatusForUser(req.userId));
},
);
app.get(
"/ssh/tunnel/status/stream",
authenticateJWT,
(req: AuthenticatedRequest, res: Response) => {
if (!req.userId) {
return res.status(401).json({ error: "Authentication required" });
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
tunnelStatusClients.set(res, req.userId);
sendTunnelStatusSnapshot(res);
const heartbeat = setInterval(() => {
try {
res.write(": keepalive\n\n");
} catch {
closeStream();
}
}, 30000);
const closeStream = () => {
clearInterval(heartbeat);
tunnelStatusClients.delete(res);
};
req.on("close", closeStream);
},
);
/**
* @openapi
* /ssh/tunnel/status/{tunnelName}:
* get:
* summary: Get tunnel status by name
* description: Retrieves the status of a specific SSH tunnel by its name.
* tags:
* - SSH Tunnels
* parameters:
* - in: path
* name: tunnelName
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Tunnel status.
* 404:
* description: Tunnel not found.
*/
app.get(
"/ssh/tunnel/status/:tunnelName",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
if (!req.userId) {
return res.status(401).json({ error: "Authentication required" });
}
const tunnelNameParam = req.params.tunnelName;
const tunnelName = Array.isArray(tunnelNameParam)
? tunnelNameParam[0]
: tunnelNameParam;
// 404 rather than 403 for foreign tunnels: the name itself carries
// host metadata, so confirming its existence would leak it.
if (!(await canAccessTunnel(req.userId, tunnelName))) {
return res.status(404).json({ error: "Tunnel not found" });
}
const status = connectionStatus.get(tunnelName);
if (!status) {
return res.status(404).json({ error: "Tunnel not found" });
}
res.json({ name: tunnelName, status });
},
);
/**
* @openapi
* /ssh/tunnel/connect:
* post:
* summary: Connect SSH tunnel
* description: Establishes an SSH tunnel connection with the specified configuration.
* tags:
* - SSH Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* sourceHostId:
* type: integer
* tunnelIndex:
* type: integer
* responses:
* 200:
* description: Connection request received.
* 400:
* description: Invalid tunnel configuration.
* 401:
* description: Authentication required.
* 403:
* description: Access denied to this host.
* 500:
* description: Failed to connect tunnel.
*/
app.post(
"/ssh/tunnel/connect",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const tunnelConfig: TunnelConfig = req.body;
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!tunnelConfig || !tunnelConfig.name) {
return res.status(400).json({ error: "Invalid tunnel configuration" });
}
const tunnelName = tunnelConfig.name;
// The "web:" prefix is reserved for web endpoint tunnels, which skip the
// retry machinery by name (see handleDisconnect). A user-supplied tunnel
// using it would silently lose its own retry/reconnect behaviour, and
// could collide with a live web endpoint forward. Rejected here, at the
// only point tunnel names are accepted from a request -- note
// validateTunnelConfig returns true unconditionally for any name that is
// not the legacy 6-part format, so it is no defence.
if (isReservedTunnelName(tunnelName)) {
return res.status(400).json({
error: `Tunnel names beginning with "${RESERVED_TUNNEL_NAME_PREFIX}" are reserved`,
});
}
tunnelConfig.requestingUserId = userId;
try {
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
tunnelLogger.error(`Tunnel config validation failed`, {
operation: "tunnel_connect",
tunnelName,
configHostId: tunnelConfig.sourceHostId,
configTunnelIndex: tunnelConfig.tunnelIndex,
});
return res.status(400).json({
error: "Tunnel configuration does not match tunnel name",
});
}
if (tunnelConfig.sourceHostId) {
const accessInfo = await permissionManager.canAccessHost(
userId,
tunnelConfig.sourceHostId,
"connect",
);
if (!accessInfo.hasAccess) {
tunnelLogger.warn("User attempted tunnel connect without access", {
operation: "tunnel_connect_unauthorized",
userId,
hostId: tunnelConfig.sourceHostId,
tunnelName,
});
return res
.status(403)
.json({ error: "Access denied to this host" });
}
}
if (pendingTunnelOperations.has(tunnelName)) {
try {
await pendingTunnelOperations.get(tunnelName);
} catch {
tunnelLogger.warn(`Previous tunnel operation failed`, {
tunnelName,
});
}
}
const operation = (async () => {
manualDisconnects.delete(tunnelName);
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
await cleanupTunnelResources(tunnelName);
if (tunnelConfigs.has(tunnelName)) {
const existingConfig = tunnelConfigs.get(tunnelName);
if (
existingConfig &&
(existingConfig.sourceHostId !== tunnelConfig.sourceHostId ||
existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex)
) {
throw new Error(`Tunnel name collision detected: ${tunnelName}`);
}
}
if (
!isSingleHostTunnel(tunnelConfig) &&
(!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername)
) {
try {
const systemCrypto = SystemCrypto.getInstance();
const internalAuthToken =
await systemCrypto.getInternalAuthToken();
const allHostsResponse = await axios.get(
"http://localhost:30001/host/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
"X-Internal-Auth-Token": internalAuthToken,
},
},
);
const allHosts: SSHHost[] = allHostsResponse.data || [];
const endpointHost = findHostByTunnelEndpoint(
allHosts,
tunnelConfig.endpointHost,
);
if (!endpointHost) {
if (getTunnelMode(tunnelConfig) !== "remote") {
tunnelConfig.endpointIP =
tunnelConfig.endpointIP || tunnelConfig.endpointHost;
} else {
throw new Error(
`Endpoint host '${tunnelConfig.endpointHost}' not found in database`,
);
}
} else {
if (!endpointHost.id) {
throw new Error("Endpoint host not found");
}
const endpointAccess = await permissionManager.canAccessHost(
userId,
endpointHost.id,
"connect",
);
if (!endpointAccess.hasAccess) {
tunnelLogger.warn(
"User attempted tunnel connect without endpoint access",
{
operation: "tunnel_connect_endpoint_unauthorized",
userId,
hostId: endpointHost.id,
tunnelName,
},
);
throw new Error("Endpoint host not found");
}
tunnelConfig.endpointIP = endpointHost.ip;
tunnelConfig.endpointSSHPort = endpointHost.port;
tunnelConfig.endpointUsername = endpointHost.username;
tunnelConfig.endpointAuthMethod = endpointHost.authType;
tunnelConfig.endpointKeyType = endpointHost.keyType;
tunnelConfig.endpointCredentialId =
endpointHost.userId === userId
? endpointHost.credentialId
: undefined;
tunnelConfig.endpointUserId = userId;
// Resolve credentials server-side instead of from HTTP response
if (endpointHost.id) {
try {
const { resolveHostById } =
await import("../host-resolver.js");
const resolved = await resolveHostById(
endpointHost.id,
userId,
);
if (resolved) {
tunnelConfig.endpointPassword = resolved.password;
tunnelConfig.endpointSSHKey = resolved.key;
tunnelConfig.endpointKeyPassword = resolved.keyPassword;
}
} catch (credError) {
tunnelLogger.warn(
"Failed to resolve endpoint credentials from DB",
{
operation: "tunnel_endpoint_credential_resolve",
endpointHostId: endpointHost.id,
error: getErrorMessage(credError, "Unknown"),
},
);
}
}
}
} catch (resolveError) {
tunnelLogger.error(
"Failed to resolve endpoint host",
resolveError,
{
operation: "tunnel_connect_resolve_endpoint_failed",
tunnelName,
endpointHost: tunnelConfig.endpointHost,
},
);
throw new Error(
`Failed to resolve endpoint host: ${getErrorMessage(resolveError)}`,
{ cause: resolveError },
);
}
}
tunnelConfigs.set(tunnelName, tunnelConfig);
await connectSSHTunnel(tunnelConfig, 0);
})();
pendingTunnelOperations.set(tunnelName, operation);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "tunnel_connect",
resourceType: "tunnel",
resourceId: tunnelConfig.sourceHostId
? String(tunnelConfig.sourceHostId)
: undefined,
resourceName: tunnelName,
details: JSON.stringify({
endpointHost: tunnelConfig.endpointHost,
endpointPort: tunnelConfig.endpointPort,
sourcePort: tunnelConfig.sourcePort,
}),
ipAddress,
userAgent,
success: true,
});
res.json({ message: "Connection request received", tunnelName });
operation
.catch((err) => {
tunnelLogger.error("Tunnel operation failed", err, {
operation: "tunnel_operation_failed",
tunnelName,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: getErrorMessage(err),
});
tunnelConnecting.delete(tunnelName);
})
.finally(() => {
pendingTunnelOperations.delete(tunnelName);
});
} catch (error) {
tunnelLogger.error("Failed to process tunnel connect", error, {
operation: "tunnel_connect",
tunnelName,
userId,
});
res.status(500).json({ error: "Failed to connect tunnel" });
}
},
);
/**
* @openapi
* /ssh/tunnel/disconnect:
* post:
* summary: Disconnect SSH tunnel
* description: Disconnects an active SSH tunnel.
* tags:
* - SSH 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.
* 401:
* description: Authentication required.
* 403:
* description: Access denied.
* 500:
* description: Failed to disconnect tunnel.
*/
app.post(
"/ssh/tunnel/disconnect",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const { tunnelName } = req.body;
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!tunnelName) {
return res.status(400).json({ error: "Tunnel name required" });
}
try {
const config = tunnelConfigs.get(tunnelName);
const authorized = await authorizeTunnelAction(
userId,
tunnelName,
config,
);
if (!authorized.allowed) {
return res.status(403).json({ error: "Access denied" });
}
tunnelLogger.info("Tunnel stop request received", {
operation: "tunnel_stop_request",
userId,
hostId: config?.sourceHostId,
tunnelName,
});
manualDisconnects.add(tunnelName);
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
clearTimeout(activeRetryTimers.get(tunnelName)!);
activeRetryTimers.delete(tunnelName);
}
await cleanupTunnelResources(tunnelName, true);
tunnelLogger.info("Tunnel cleanup completed", {
operation: "tunnel_cleanup_complete",
userId,
hostId: config?.sourceHostId,
tunnelName,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true,
});
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, tunnelConfig, false);
setTimeout(() => {
manualDisconnects.delete(tunnelName);
}, 5000);
res.json({ message: "Disconnect request received", tunnelName });
} catch (error) {
tunnelLogger.error("Failed to disconnect tunnel", error, {
operation: "tunnel_disconnect",
tunnelName,
userId,
});
res.status(500).json({ error: "Failed to disconnect tunnel" });
}
},
);
/**
* @openapi
* /ssh/tunnel/cancel:
* post:
* summary: Cancel tunnel retry
* description: Cancels the retry mechanism for a failed SSH tunnel connection.
* tags:
* - SSH 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.
* 401:
* description: Authentication required.
* 403:
* description: Access denied.
* 500:
* description: Failed to cancel tunnel retry.
*/
app.post(
"/ssh/tunnel/cancel",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const { tunnelName } = req.body;
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!tunnelName) {
return res.status(400).json({ error: "Tunnel name required" });
}
try {
const config = tunnelConfigs.get(tunnelName);
const authorized = await authorizeTunnelAction(
userId,
tunnelName,
config,
);
if (!authorized.allowed) {
return res.status(403).json({ error: "Access denied" });
}
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
clearTimeout(activeRetryTimers.get(tunnelName)!);
activeRetryTimers.delete(tunnelName);
}
if (countdownIntervals.has(tunnelName)) {
clearInterval(countdownIntervals.get(tunnelName)!);
countdownIntervals.delete(tunnelName);
}
await cleanupTunnelResources(tunnelName, true);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true,
});
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, tunnelConfig, false);
setTimeout(() => {
manualDisconnects.delete(tunnelName);
}, 5000);
res.json({ message: "Cancel request received", tunnelName });
} catch (error) {
tunnelLogger.error("Failed to cancel tunnel retry", error, {
operation: "tunnel_cancel",
tunnelName,
userId,
});
res.status(500).json({ error: "Failed to cancel tunnel retry" });
}
},
);
}
-106
View File
@@ -1,106 +0,0 @@
import type { Duplex } from "stream";
import { tunnelLogger } from "../../utils/logger.js";
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 };
}
export function handleSocks5Connect(
inbound: Duplex,
openOutbound: (host: string, port: number) => Promise<Duplex>,
tunnelName: string,
): 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) => {
tunnelLogger.error("SOCKS5 tunnel connect failed", error, {
operation: "managed_tunnel_socks_connect_failed",
tunnelName,
targetHost: parsed.address,
targetPort: parsed.port,
});
fail(0x05);
});
}
};
inbound.on("data", onData);
inbound.on("error", () => inbound.destroy());
}
-234
View File
@@ -1,234 +0,0 @@
import { Client, type ClientChannel } from "ssh2";
import type { Duplex } from "stream";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { tunnelLogger } from "../../utils/logger.js";
import { getSshAuthProvider } from "../connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../connect/core-providers.js";
import type { MutableConnectConfig, SshAuthOutcome } from "../connect/types.js";
export function getManagedTunnelAlgorithms() {
return {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
};
}
export interface TunnelCredentials {
password?: string;
sshKey?: string;
keyPassword?: string;
keyType?: string;
authMethod?: string;
certPublicKey?: string | null;
terminalConfig?: Record<string, unknown> | null;
vaultProfile?: { id?: number | null } | null;
}
/**
* Puts a tunnel side's auth into its connect options through the same
* providers every other transport uses, so tunnels accept every auth type.
*/
export async function applyTunnelAuth(
connOptions: Record<string, unknown>,
credentials: TunnelCredentials,
env: {
client: Client;
userId?: string | null;
hostId?: number | null;
username: string;
ip: string;
port: number;
},
): Promise<SshAuthOutcome> {
ensureCoreSshAuthProviders();
const authType =
credentials.authMethod || (credentials.sshKey ? "key" : "password");
const provider = getSshAuthProvider(authType);
if (!provider) {
return {
status: "error",
code: "provider-missing",
message: `Unsupported authentication type '${authType}' for tunnels`,
};
}
const outcome = await provider.prepare(
connOptions as MutableConnectConfig,
{
id: env.hostId ?? 0,
ip: env.ip,
port: env.port,
username: env.username,
userId: env.userId,
authType,
password: credentials.password,
key: credentials.sshKey,
keyPassword: credentials.keyPassword,
keyType: credentials.keyType,
certPublicKey: credentials.certPublicKey,
terminalConfig: credentials.terminalConfig ?? null,
vaultProfile: credentials.vaultProfile ?? null,
},
{
client: env.client,
userId: env.userId ?? "",
hostId: env.hostId ?? 0,
purpose: "tunnel",
interactive: false,
log: () => {},
},
);
if (
outcome.status === "ready" &&
connOptions.privateKey &&
credentials.keyType &&
credentials.keyType !== "auto"
) {
connOptions.privateKeyType = credentials.keyType;
}
return outcome;
}
export function connectClient(
connOptions: Record<string, unknown>,
tunnelName: string,
role: "source" | "endpoint",
client: Client = new Client(),
): Promise<Client> {
return new Promise((resolve, reject) => {
let settled = false;
client.once("ready", () => {
settled = true;
resolve(client);
});
client.once("error", (error) => {
if (!settled) {
reject(error);
return;
}
tunnelLogger.error("Managed tunnel SSH client error", error, {
operation: "managed_tunnel_client_error",
tunnelName,
role,
});
});
client.connect(connOptions);
});
}
export function forwardOut(
client: Client,
targetHost: string,
targetPort: number,
tunnelName?: string,
): Promise<ClientChannel> {
return new Promise((resolve, reject) => {
client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
if (err) {
if (tunnelName) {
tunnelLogger.error("Managed tunnel forwardOut failed", err, {
operation: "managed_tunnel_forward_out_failed",
tunnelName,
targetHost,
targetPort,
});
}
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,
): void {
try {
client.unforwardIn(bindHost, bindPort, (err) => {
if (err) {
tunnelLogger.warn("Failed to unbind managed tunnel listener", {
operation: "managed_tunnel_unforward_failed",
bindHost,
bindPort,
error: err.message,
});
}
});
} catch {
// The connection may already be gone.
}
}
export function pipeTunnelStreams(
inbound: Duplex,
outboundPromise: Promise<Duplex>,
tunnelName: string,
): void {
outboundPromise
.then((outbound) => {
inbound.pipe(outbound).pipe(inbound);
inbound.on("error", () => outbound.destroy());
outbound.on("error", () => inbound.destroy());
})
.catch((error) => {
tunnelLogger.error(
"Failed to open managed tunnel outbound stream",
error,
{
operation: "managed_tunnel_outbound_failed",
tunnelName,
},
);
inbound.destroy();
});
}
-192
View File
@@ -1,192 +0,0 @@
import type { ErrorType, TunnelConfig } from "../../../types/index.js";
import { tunnelLogger } from "../../utils/logger.js";
export function classifyTunnelError(errorMessage: string): ErrorType {
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("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";
}
export function getTunnelMarker(tunnelName: string): string {
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
}
export function normalizeTunnelName(
hostId: number,
tunnelIndex: number,
displayName: string,
sourcePort: number,
endpointHost: string,
endpointPort: number,
): string {
return `${hostId}::${tunnelIndex}::${displayName}::${sourcePort}::${endpointHost}::${endpointPort}`;
}
export function getTunnelMode(
tunnelConfig: TunnelConfig,
): "local" | "remote" | "dynamic" {
return tunnelConfig.mode || tunnelConfig.tunnelType || "remote";
}
export function getTunnelScope(tunnelConfig: TunnelConfig): "s2s" | "c2s" {
return tunnelConfig.scope || "s2s";
}
export function getTunnelBindHost(tunnelConfig: TunnelConfig): 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,
};
}
tunnelLogger.warn(`Legacy tunnel name format: ${tunnelName}`);
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: TunnelConfig,
): 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 web endpoint
* tunnels: they are opened on demand and never retried on disconnect (see the
* early return in `handleDisconnect`). A user-supplied tunnel name using this
* prefix would silently disable its own retry/reconnect behaviour, and could
* collide with a live web endpoint forward -- so the prefix is rejected at the
* point tunnel names are accepted from a request (the /ssh/tunnel/connect
* route).
*/
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`. Routes that open or close a
* web endpoint tunnel must call this rather than composing the string inline,
* so the two can never drift apart.
*/
export function buildWebEndpointTunnelName(
hostId: number,
endpointId: string,
): string {
return `${RESERVED_TUNNEL_NAME_PREFIX}${hostId}:${endpointId}`;
}
/**
* Recovers the host id and endpoint id encoded in a web endpoint tunnel name,
* so a route that only has the name -- /ssh/tunnel/disconnect is not told
* which host a reserved tunnel belongs to -- can still perform an ownership
* check.
*
* Returns null for anything that is not a reserved name, or whose host id
* segment is not a positive integer. Callers must treat null as "cannot
* verify ownership" and FAIL CLOSED, never fall through to an unchecked path.
*
* The endpoint id is taken verbatim as everything after the first ":"
* following the host id, so it may itself contain colons -- endpoint ids are
* client-supplied with no character restriction. Splitting on the first colon
* rather than the last is safe because the host id segment is digits only.
*/
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 };
}