From 5e7090542d316b632d397e98dce15e019c835391 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 16 Jul 2026 04:26:31 -0500 Subject: [PATCH] refactor(ssh): split tunnel module into layered directory ssh/tunnel/{index,routes,manager}.ts: server boot in index, HTTP handlers in routes, tunnel state and engine (connect/retry/autostart) in manager. Code motion only; port 30003 and endpoints unchanged. --- src/backend/ssh/tunnel/index.ts | 92 +++ .../ssh/{tunnel.ts => tunnel/manager.ts} | 763 ++---------------- src/backend/ssh/tunnel/routes.ts | 596 ++++++++++++++ src/backend/starter.ts | 2 +- 4 files changed, 758 insertions(+), 695 deletions(-) create mode 100644 src/backend/ssh/tunnel/index.ts rename src/backend/ssh/{tunnel.ts => tunnel/manager.ts} (71%) create mode 100644 src/backend/ssh/tunnel/routes.ts diff --git a/src/backend/ssh/tunnel/index.ts b/src/backend/ssh/tunnel/index.ts new file mode 100644 index 00000000..555328e0 --- /dev/null +++ b/src/backend/ssh/tunnel/index.ts @@ -0,0 +1,92 @@ +import express from "express"; +import { createServer } from "http"; + +import { createCorsMiddleware } from "../../utils/cors-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 "../tunnel-c2s-relay-utils.js"; +import { + handleC2SRelayOpen, + handleC2SRelayTest, + type C2SOpenMessage, +} from "../tunnel-c2s-relay.js"; + +import { registerTunnelRoutes } from "./routes.js"; +import { initializeAutoStartTunnels } from "./manager.js"; + +const authManager = AuthManager.getInstance(); + +const app = express(); +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("connection", (ws, req) => { + let opened = false; + + 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", + }); + } + }); +}); + +server.listen(PORT, () => { + setTimeout(() => { + initializeAutoStartTunnels(); + }, 2000); +}); diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel/manager.ts similarity index 71% rename from src/backend/ssh/tunnel.ts rename to src/backend/ssh/tunnel/manager.ts index 2529062a..e6ec2217 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel/manager.ts @@ -1,35 +1,28 @@ -import express, { type Response } from "express"; -import { createServer } from "http"; +import { type Response } from "express"; import { createServer as createTcpServer, Socket as TcpSocket, type Server as TcpServer, } from "net"; -import { createCorsMiddleware } from "../utils/cors-config.js"; -import cookieParser from "cookie-parser"; import { Client, type ClientChannel } from "ssh2"; -import { WebSocketServer } from "ws"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; +import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { ChildProcess } from "child_process"; import axios from "axios"; -import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js"; +import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; import type { SSHHost, TunnelConfig, TunnelStatus, VerificationData, - AuthenticatedRequest, -} from "../../types/index.js"; -import { CONNECTION_STATES } from "../../types/index.js"; -import { tunnelLogger } from "../utils/logger.js"; -import { logAudit } from "../utils/audit-logger.js"; -import { SystemCrypto } from "../utils/system-crypto.js"; -import { DataCrypto } from "../utils/data-crypto.js"; -import { createSocks5Connection } from "../utils/socks5-helper.js"; -import { AuthManager } from "../utils/auth-manager.js"; -import { PermissionManager } from "../utils/permission-manager.js"; -import { withConnection } from "./ssh-connection-pool.js"; -import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; +} from "../../../types/index.js"; +import { CONNECTION_STATES } from "../../../types/index.js"; +import { tunnelLogger } from "../../utils/logger.js"; +import { logAudit } from "../../utils/audit-logger.js"; +import { SystemCrypto } from "../../utils/system-crypto.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { createSocks5Connection } from "../../utils/socks5-helper.js"; +import { withConnection } from "../ssh-connection-pool.js"; +import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; import { applyAuthOptions, bindForwardIn, @@ -38,7 +31,7 @@ import { getManagedTunnelAlgorithms, pipeTunnelStreams, unbindForwardIn, -} from "./tunnel-ssh-primitives.js"; +} from "../tunnel-ssh-primitives.js"; import { classifyTunnelError, getTunnelBindHost, @@ -46,61 +39,41 @@ import { getTunnelMode, getTunnelScope, normalizeTunnelName, - validateTunnelConfig, -} from "./tunnel-utils.js"; -import { - describeC2SRelayError, - extractRequestToken, - sendC2SError, -} from "./tunnel-c2s-relay-utils.js"; -import { - handleC2SRelayOpen, - handleC2SRelayTest, - type C2SOpenMessage, -} from "./tunnel-c2s-relay.js"; -import { resolveSshConnectConfigHost } from "./ssh-dns.js"; -import { handleSocks5Connect } from "./tunnel-socks5-relay.js"; +} from "../tunnel-utils.js"; -const app = express(); -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(); -}); +import { resolveSshConnectConfigHost } from "../ssh-dns.js"; +import { handleSocks5Connect } from "../tunnel-socks5-relay.js"; -const authManager = AuthManager.getInstance(); -const permissionManager = PermissionManager.getInstance(); -const authenticateJWT = authManager.createAuthMiddleware(); +export const activeTunnels = new Map(); +export const retryCounters = new Map(); +export const connectionStatus = new Map(); +export const tunnelVerifications = new Map(); +export const manualDisconnects = new Set(); +export const verificationTimers = new Map(); +export const activeRetryTimers = new Map(); +export const countdownIntervals = new Map(); +export const retryExhaustedTunnels = new Set(); +export const cleanupInProgress = new Set(); +export const tunnelConnecting = new Set(); +export const lastTunnelErrors = new Map(); +export const lastTunnelErrorTypes = new Map< + string, + TunnelStatus["errorType"] +>(); -const activeTunnels = new Map(); -const retryCounters = new Map(); -const connectionStatus = new Map(); -const tunnelVerifications = new Map(); -const manualDisconnects = new Set(); -const verificationTimers = new Map(); -const activeRetryTimers = new Map(); -const countdownIntervals = new Map(); -const retryExhaustedTunnels = new Set(); -const cleanupInProgress = new Set(); -const tunnelConnecting = new Set(); -const lastTunnelErrors = new Map(); -const lastTunnelErrorTypes = new Map(); +export const tunnelConfigs = new Map(); +export const activeTunnelProcesses = new Map(); +export const pendingTunnelOperations = new Map>(); +export const tunnelStatusClients = new Set(); -const tunnelConfigs = new Map(); -const activeTunnelProcesses = new Map(); -const pendingTunnelOperations = new Map>(); -const tunnelStatusClients = new Set(); +export const INTERNAL_HOST_API_BASE_URL = "http://localhost:30001/host/db/host"; +export const AUTOSTART_FETCH_RETRIES = 6; -const INTERNAL_HOST_API_BASE_URL = "http://localhost:30001/host/db/host"; -const AUTOSTART_FETCH_RETRIES = 6; - -function sleep(ms: number): Promise { +export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function describeAxiosError(error: unknown): string { +export function describeAxiosError(error: unknown): string { if (axios.isAxiosError(error)) { return error.response ? `${error.response.status} ${error.response.statusText}` @@ -110,7 +83,7 @@ function describeAxiosError(error: unknown): string { return error instanceof Error ? error.message : "Unknown error"; } -async function fetchInternalHosts( +export async function fetchInternalHosts( path: "internal" | "internal/all", internalAuthToken: string, ): Promise { @@ -153,7 +126,7 @@ async function fetchInternalHosts( ); } -type ActiveTunnelRuntime = { +export type ActiveTunnelRuntime = { sourceClient: Client; endpointClient?: Client; bindClient?: Client; @@ -163,9 +136,9 @@ type ActiveTunnelRuntime = { close: () => void; }; -const activeTunnelRuntimes = new Map(); +export const activeTunnelRuntimes = new Map(); -function findHostByTunnelEndpoint( +export function findHostByTunnelEndpoint( hosts: SSHHost[], endpointHost?: string, ): SSHHost | undefined { @@ -183,7 +156,10 @@ function findHostByTunnelEndpoint( }); } -function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { +export function broadcastTunnelStatus( + tunnelName: string, + status: TunnelStatus, +): void { if ( status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName) @@ -229,7 +205,7 @@ function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { broadcastTunnelStatusSnapshot(); } -function getAllTunnelStatus(): Record { +export function getAllTunnelStatus(): Record { const tunnelStatus: Record = {}; connectionStatus.forEach((status, key) => { tunnelStatus[key] = status; @@ -237,7 +213,7 @@ function getAllTunnelStatus(): Record { return tunnelStatus; } -function sendTunnelStatusSnapshot(res: Response): void { +export function sendTunnelStatusSnapshot(res: Response): void { try { res.write( `event: statuses\ndata: ${JSON.stringify(getAllTunnelStatus())}\n\n`, @@ -247,13 +223,13 @@ function sendTunnelStatusSnapshot(res: Response): void { } } -function broadcastTunnelStatusSnapshot(): void { +export function broadcastTunnelStatusSnapshot(): void { for (const client of tunnelStatusClients) { sendTunnelStatusSnapshot(client); } } -async function cleanupTunnelResources( +export async function cleanupTunnelResources( tunnelName: string, forceCleanup = false, ): Promise { @@ -366,7 +342,7 @@ async function cleanupTunnelResources( } } -function resetRetryState(tunnelName: string): void { +export function resetRetryState(tunnelName: string): void { retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); lastTunnelErrors.delete(tunnelName); @@ -393,7 +369,7 @@ function resetRetryState(tunnelName: string): void { }); } -async function handleDisconnect( +export async function handleDisconnect( tunnelName: string, tunnelConfig: TunnelConfig | null, shouldRetry = true, @@ -534,7 +510,7 @@ async function handleDisconnect( } } -function setupPingInterval(tunnelName: string): void { +export function setupPingInterval(tunnelName: string): void { const pingKey = `${tunnelName}_ping`; if (verificationTimers.has(pingKey)) { clearInterval(verificationTimers.get(pingKey)!); @@ -562,7 +538,7 @@ function setupPingInterval(tunnelName: string): void { verificationTimers.set(pingKey, pingInterval); } -async function connectEndpointThroughSource( +export async function connectEndpointThroughSource( sourceClient: Client, tunnelConfig: TunnelConfig, endpointCredentials: { @@ -595,7 +571,7 @@ async function connectEndpointThroughSource( return connectClient(endpointOptions, tunnelConfig.name, "endpoint"); } -function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string { +export function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string { const targetHost = tunnelConfig.targetHost?.trim(); if ( @@ -609,7 +585,7 @@ function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string { return targetHost; } -function isSingleHostTunnel(tunnelConfig: TunnelConfig): boolean { +export function isSingleHostTunnel(tunnelConfig: TunnelConfig): boolean { if (!tunnelConfig.endpointHost && !tunnelConfig.endpointIP) return true; if ( tunnelConfig.endpointHost === "127.0.0.1" || @@ -627,13 +603,15 @@ function isSingleHostTunnel(tunnelConfig: TunnelConfig): boolean { return false; } -function shouldEstablishDirectTunnel(tunnelConfig: TunnelConfig): boolean { +export function shouldEstablishDirectTunnel( + tunnelConfig: TunnelConfig, +): boolean { if (isSingleHostTunnel(tunnelConfig)) return true; const mode = getTunnelMode(tunnelConfig); return mode !== "remote" && !tunnelConfig.endpointUsername; } -async function establishDirectTunnel( +export async function establishDirectTunnel( sourceClient: Client, tunnelConfig: TunnelConfig, ): Promise { @@ -760,7 +738,7 @@ async function establishDirectTunnel( activeTunnels.set(tunnelName, sourceClient); } -async function establishManagedS2STunnel( +export async function establishManagedS2STunnel( sourceClient: Client, tunnelConfig: TunnelConfig, endpointCredentials: { @@ -871,7 +849,7 @@ async function establishManagedS2STunnel( activeTunnels.set(tunnelName, sourceClient); } -async function connectSSHTunnel( +export async function connectSSHTunnel( tunnelConfig: TunnelConfig, retryAttempt = 0, ): Promise { @@ -957,7 +935,7 @@ async function connectSSHTunnel( !tunnelConfig.sourceSSHKey ) { try { - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById( tunnelConfig.sourceHostId, effectiveUserId, @@ -995,7 +973,7 @@ async function connectSSHTunnel( // Legacy: credential resolution from credentialId try { if (tunnelConfig.sourceHostId) { - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById( tunnelConfig.sourceHostId, effectiveUserId, @@ -1522,7 +1500,7 @@ async function connectSSHTunnel( conn.connect(connOptions); } -async function killRemoteTunnelByMarker( +export async function killRemoteTunnelByMarker( tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void, @@ -1551,7 +1529,7 @@ async function killRemoteTunnelByMarker( !tunnelConfig.sourceSSHKey ) { try { - const { resolveHostById } = await import("./host-resolver.js"); + const { resolveHostById } = await import("../host-resolver.js"); const resolvedHost = await resolveHostById( tunnelConfig.sourceHostId, tunnelConfig.sourceUserId, @@ -1814,555 +1792,8 @@ async function killRemoteTunnelByMarker( * 200: * description: A list of all tunnel statuses. */ -app.get( - "/ssh/tunnel/status", - authenticateJWT, - (req: AuthenticatedRequest, res: Response) => { - if (!req.userId) { - return res.status(401).json({ error: "Authentication required" }); - } - res.json(getAllTunnelStatus()); - }, -); - -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.add(res); - 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, - (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; - 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; - 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, - "read", - ); - - 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, - "read", - ); - 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: - credError instanceof Error - ? credError.message - : "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: ${resolveError instanceof Error ? resolveError.message : "Unknown error"}`, - { cause: resolveError }, - ); - } - } - - tunnelConfigs.set(tunnelName, tunnelConfig); - await connectSSHTunnel(tunnelConfig, 0); - })(); - - pendingTunnelOperations.set(tunnelName, operation); - - 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: err instanceof Error ? err.message : "Unknown error", - }); - 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); - if (config && config.sourceHostId) { - const accessInfo = await permissionManager.canAccessHost( - userId, - config.sourceHostId, - "read", - ); - if (!accessInfo.hasAccess) { - 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); - if (config && config.sourceHostId) { - const accessInfo = await permissionManager.canAccessHost( - userId, - config.sourceHostId, - "read", - ); - if (!accessInfo.hasAccess) { - 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" }); - } - }, -); - -async function initializeAutoStartTunnels(): Promise { +export async function initializeAutoStartTunnels(): Promise { try { const systemCrypto = SystemCrypto.getInstance(); const internalAuthToken = await systemCrypto.getInternalAuthToken(); @@ -2473,59 +1904,3 @@ async function initializeAutoStartTunnels(): Promise { ); } } - -const PORT = 30003; -const server = createServer(app); -const c2sRelayWss = new WebSocketServer({ - server, - path: "/ssh/tunnel/c2s/stream", -}); - -c2sRelayWss.on("connection", (ws, req) => { - let opened = false; - - 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", - }); - } - }); -}); - -server.listen(PORT, () => { - setTimeout(() => { - initializeAutoStartTunnels(); - }, 2000); -}); diff --git a/src/backend/ssh/tunnel/routes.ts b/src/backend/ssh/tunnel/routes.ts new file mode 100644 index 00000000..4cb44d04 --- /dev/null +++ b/src/backend/ssh/tunnel/routes.ts @@ -0,0 +1,596 @@ +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 { 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 { getTunnelMode, validateTunnelConfig } from "../tunnel-utils.js"; + +import { + tunnelConfigs, + activeRetryTimers, + pendingTunnelOperations, + manualDisconnects, + retryExhaustedTunnels, + retryCounters, + countdownIntervals, + tunnelConnecting, + tunnelStatusClients, + connectionStatus, + cleanupTunnelResources, + broadcastTunnelStatus, + handleDisconnect, + sendTunnelStatusSnapshot, + isSingleHostTunnel, + getAllTunnelStatus, + findHostByTunnelEndpoint, + connectSSHTunnel, +} from "./manager.js"; + +const permissionManager = PermissionManager.getInstance(); + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +export function registerTunnelRoutes(app: express.Express): void { + app.get( + "/ssh/tunnel/status", + authenticateJWT, + (req: AuthenticatedRequest, res: Response) => { + if (!req.userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + res.json(getAllTunnelStatus()); + }, + ); + + 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.add(res); + 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, + (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; + 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; + 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, + "read", + ); + + 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, + "read", + ); + 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: + credError instanceof Error + ? credError.message + : "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: ${resolveError instanceof Error ? resolveError.message : "Unknown error"}`, + { cause: resolveError }, + ); + } + } + + tunnelConfigs.set(tunnelName, tunnelConfig); + await connectSSHTunnel(tunnelConfig, 0); + })(); + + pendingTunnelOperations.set(tunnelName, operation); + + 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: err instanceof Error ? err.message : "Unknown error", + }); + 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); + if (config && config.sourceHostId) { + const accessInfo = await permissionManager.canAccessHost( + userId, + config.sourceHostId, + "read", + ); + if (!accessInfo.hasAccess) { + 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); + if (config && config.sourceHostId) { + const accessInfo = await permissionManager.canAccessHost( + userId, + config.sourceHostId, + "read", + ); + if (!accessInfo.hasAccess) { + 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" }); + } + }, + ); +} diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 2cea125c..77e83ad7 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -155,7 +155,7 @@ import { const dbServer = await import("./database/database.js"); await (dbServer as unknown as { serverReady: Promise }).serverReady; await import("./ssh/terminal.js"); - await import("./ssh/tunnel.js"); + await import("./ssh/tunnel/index.js"); await import("./ssh/file-manager.js"); await import("./ssh/host-metrics.js"); await import("./ssh/docker/index.js");