diff --git a/src/apps/SSH/Tunnel/SSHTunnel.tsx b/src/apps/SSH/Tunnel/SSHTunnel.tsx index b675128e..b3ca2386 100644 --- a/src/apps/SSH/Tunnel/SSHTunnel.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnel.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx"; import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx"; -import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel } from "@/apps/SSH/ssh-axios"; +import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel } from "@/apps/SSH/ssh-axios"; interface ConfigEditorProps { onSelectView: (view: string) => void; @@ -39,16 +39,6 @@ interface SSHHost { updatedAt: string; } -interface HostStatus { - connectionState?: string; - statusReason?: string; - statusErrorType?: string; - statusRetryCount?: number; - statusMaxRetries?: number; - statusNextRetryIn?: number; - statusRetryExhausted?: boolean; -} - interface TunnelStatus { status: string; reason?: string; @@ -61,7 +51,8 @@ interface TunnelStatus { export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement { const [hosts, setHosts] = useState([]); - const [hostStatuses, setHostStatuses] = useState>({}); + const [tunnelStatuses, setTunnelStatuses] = useState>({}); + const [tunnelActions, setTunnelActions] = useState>({}); // Track loading states const fetchHosts = useCallback(async () => { try { @@ -76,48 +67,11 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme const fetchTunnelStatuses = useCallback(async () => { try { const statusData = await getTunnelStatuses(); - - // Convert tunnel statuses to host statuses - const newHostStatuses: Record = {}; - - hosts.forEach(host => { - // Find all tunnel statuses for this host - const hostName = host.name || `${host.username}@${host.ip}`; - const hostTunnelStatuses: TunnelStatus[] = []; - - // Look for tunnel statuses that start with this host name - Object.entries(statusData).forEach(([tunnelName, status]) => { - if (tunnelName.startsWith(hostName + '_')) { - hostTunnelStatuses.push(status as any); - } - }); - - if (hostTunnelStatuses.length > 0) { - // Just use the first tunnel's status for now - simplify - const firstTunnelStatus = hostTunnelStatuses[0]; - - newHostStatuses[host.id] = { - connectionState: firstTunnelStatus.status, - statusReason: firstTunnelStatus.reason, - statusErrorType: firstTunnelStatus.errorType, - statusRetryCount: firstTunnelStatus.retryCount, - statusMaxRetries: firstTunnelStatus.maxRetries, - statusNextRetryIn: firstTunnelStatus.nextRetryIn, - statusRetryExhausted: firstTunnelStatus.retryExhausted, - }; - } else { - // Set default disconnected status - newHostStatuses[host.id] = { - connectionState: 'disconnected' - }; - } - }); - - setHostStatuses(newHostStatuses); + setTunnelStatuses(statusData); } catch (err) { // Silent error handling } - }, [hosts]); + }, []); useEffect(() => { fetchHosts(); @@ -131,76 +85,66 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme return () => clearInterval(interval); }, [fetchTunnelStatuses]); - const handleConnect = async (hostId: number) => { - const host = hosts.find(h => h.id === hostId); - if (!host || !host.tunnelConnections || host.tunnelConnections.length === 0) { - return; - } - - // Let the backend handle the status updates - + const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => { + const tunnel = host.tunnelConnections[tunnelIndex]; + const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`; + + setTunnelActions(prev => ({ ...prev, [tunnelName]: true })); + try { - // For each tunnel connection, create a tunnel configuration - for (const tunnelConnection of host.tunnelConnections) { + if (action === 'connect') { // Find the endpoint host configuration const endpointHost = hosts.find(h => - h.name === tunnelConnection.endpointHost || - `${h.username}@${h.ip}` === tunnelConnection.endpointHost + h.name === tunnel.endpointHost || + `${h.username}@${h.ip}` === tunnel.endpointHost ); if (!endpointHost) { - continue; + throw new Error('Endpoint host not found'); } // Create tunnel configuration const tunnelConfig = { - name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`, + name: tunnelName, hostName: host.name || `${host.username}@${host.ip}`, sourceIP: host.ip, sourceSSHPort: host.port, sourceUsername: host.username, - sourcePassword: host.password, + sourcePassword: host.authType === 'password' ? host.password : undefined, sourceAuthMethod: host.authType, - sourceSSHKey: host.key, - sourceKeyPassword: host.keyPassword, - sourceKeyType: host.keyType, + sourceSSHKey: host.authType === 'key' ? host.key : undefined, + sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined, + sourceKeyType: host.authType === 'key' ? host.keyType : undefined, endpointIP: endpointHost.ip, endpointSSHPort: endpointHost.port, endpointUsername: endpointHost.username, - endpointPassword: endpointHost.password, + endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined, endpointAuthMethod: endpointHost.authType, - endpointSSHKey: endpointHost.key, - endpointKeyPassword: endpointHost.keyPassword, - endpointKeyType: endpointHost.keyType, - sourcePort: tunnelConnection.sourcePort, - endpointPort: tunnelConnection.endpointPort, - maxRetries: tunnelConnection.maxRetries, - retryInterval: tunnelConnection.retryInterval * 1000, // Convert to milliseconds - autoStart: tunnelConnection.autoStart, + endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined, + endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined, + endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined, + sourcePort: tunnel.sourcePort, + endpointPort: tunnel.endpointPort, + maxRetries: tunnel.maxRetries, + retryInterval: tunnel.retryInterval * 1000, // Convert to milliseconds + autoStart: tunnel.autoStart, isPinned: host.pin }; await connectTunnel(tunnelConfig); - } - } catch (err) { - // Let the backend handle error status updates - } - }; - - const handleDisconnect = async (hostId: number) => { - const host = hosts.find(h => h.id === hostId); - if (!host) return; - - // Let the backend handle the status updates - - try { - // Disconnect all tunnels for this host - for (const tunnelConnection of host.tunnelConnections) { - const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`; + } else if (action === 'disconnect') { await disconnectTunnel(tunnelName); + } else if (action === 'cancel') { + await cancelTunnel(tunnelName); } + + // Refresh statuses after action + await fetchTunnelStatuses(); } catch (err) { - // Silent error handling + console.error(`Failed to ${action} tunnel:`, err); + // Let the backend handle error status updates + } finally { + setTunnelActions(prev => ({ ...prev, [tunnelName]: false })); } }; @@ -214,9 +158,9 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
diff --git a/src/apps/SSH/Tunnel/SSHTunnelObject.tsx b/src/apps/SSH/Tunnel/SSHTunnelObject.tsx index 2af450aa..6b0c9140 100644 --- a/src/apps/SSH/Tunnel/SSHTunnelObject.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnelObject.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Button } from "@/components/ui/button.tsx"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"; import { Separator } from "@/components/ui/separator.tsx"; -import { Loader2, Pin, Terminal, Network, FileEdit, Tag } from "lucide-react"; +import { Loader2, Pin, Terminal, Network, FileEdit, Tag, Play, Square, AlertCircle, Clock, Wifi, WifiOff, Zap, X } from "lucide-react"; import { Badge } from "@/components/ui/badge.tsx"; const CONNECTION_STATES = { @@ -45,64 +45,112 @@ interface SSHHost { updatedAt: string; } +interface TunnelStatus { + status: string; + reason?: string; + errorType?: string; + retryCount?: number; + maxRetries?: number; + nextRetryIn?: number; + retryExhausted?: boolean; +} + interface SSHTunnelObjectProps { host: SSHHost; - onConnect?: (hostId: number) => void; - onDisconnect?: (hostId: number) => void; - connectionState?: keyof typeof CONNECTION_STATES | string; - statusReason?: string; - statusErrorType?: string; - statusRetryCount?: number; - statusMaxRetries?: number; - statusNextRetryIn?: number; - statusRetryExhausted?: boolean; + tunnelStatuses: Record; + tunnelActions: Record; + onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise; } export function SSHTunnelObject({ host, - onConnect, - onDisconnect, - connectionState = "DISCONNECTED", - statusReason, - statusErrorType, - statusRetryCount, - statusMaxRetries, - statusNextRetryIn, - statusRetryExhausted + tunnelStatuses, + tunnelActions, + onTunnelAction }: SSHTunnelObjectProps): React.ReactElement { - const getStatusColor = (state: string) => { - const upperState = state.toUpperCase(); - switch (upperState) { - case "CONNECTED": - return "bg-green-500"; - case "CONNECTING": - case "VERIFYING": - case "RETRYING": - case "WAITING": - return "bg-yellow-500"; - case "FAILED": - return "bg-red-500"; - case "UNSTABLE": - return "bg-orange-500"; + const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => { + const tunnel = host.tunnelConnections[tunnelIndex]; + const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`; + return tunnelStatuses[tunnelName]; + }; + + const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => { + if (!status) return { + icon: , + text: 'Unknown', + color: 'text-muted-foreground', + bgColor: 'bg-muted/50', + borderColor: 'border-border' + }; + + // Handle both the old format (status.status) and new format (status.status) + const statusValue = status.status || 'DISCONNECTED'; + + switch (statusValue.toUpperCase()) { + case 'CONNECTED': + return { + icon: , + text: 'Connected', + color: 'text-green-600 dark:text-green-400', + bgColor: 'bg-green-500/10 dark:bg-green-400/10', + borderColor: 'border-green-500/20 dark:border-green-400/20' + }; + case 'CONNECTING': + return { + icon: , + text: 'Connecting...', + color: 'text-blue-600 dark:text-blue-400', + bgColor: 'bg-blue-500/10 dark:bg-blue-400/10', + borderColor: 'border-blue-500/20 dark:border-blue-400/20' + }; + case 'DISCONNECTING': + return { + icon: , + text: 'Disconnecting...', + color: 'text-orange-600 dark:text-orange-400', + bgColor: 'bg-orange-500/10 dark:bg-orange-400/10', + borderColor: 'border-orange-500/20 dark:border-orange-400/20' + }; + case 'DISCONNECTED': + return { + icon: , + text: 'Disconnected', + color: 'text-muted-foreground', + bgColor: 'bg-muted/30', + borderColor: 'border-border' + }; + case 'WAITING': + return { + icon: , + color: 'text-blue-600 dark:text-blue-400', + bgColor: 'bg-blue-500/10 dark:bg-blue-400/10', + borderColor: 'border-blue-500/20 dark:border-blue-400/20' + }; + case 'ERROR': + case 'FAILED': + return { + icon: , + text: status.reason || 'Error', + color: 'text-red-600 dark:text-red-400', + bgColor: 'bg-red-500/10 dark:bg-red-400/10', + borderColor: 'border-red-500/20 dark:border-red-400/20' + }; default: - return "bg-gray-500"; + return { + icon: , + text: statusValue, + color: 'text-muted-foreground', + bgColor: 'bg-muted/30', + borderColor: 'border-border' + }; } }; - const getStatusText = (state: string) => { - // Just capitalize the first letter of the status from backend - return state.charAt(0).toUpperCase() + state.slice(1); - }; - - const isConnected = connectionState === "CONNECTED" || connectionState === "connected"; - const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING", "WAITING", "connecting", "verifying", "retrying", "waiting"].includes(connectionState); - const isDisconnecting = connectionState === "DISCONNECTING" || connectionState === "disconnecting"; - return ( -
+
{/* Host Header */} -
+
{host.pin && }
@@ -114,17 +162,11 @@ export function SSHTunnelObject({

-
-
- - {getStatusText(connectionState)} - -
{/* Tags */} {host.tags && host.tags.length > 0 && ( -
+
{host.tags.slice(0, 3).map((tag, index) => ( @@ -139,91 +181,143 @@ export function SSHTunnelObject({
)} - + {/* Tunnel Connections */} -
-

Tunnel Connections

+
+

+ + Tunnel Connections ({host.tunnelConnections.length}) +

{host.tunnelConnections && host.tunnelConnections.length > 0 ? ( -
- {host.tunnelConnections.map((tunnel, index) => ( -
-
- Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} - {tunnel.autoStart && ( - - Auto - +
+ {host.tunnelConnections.map((tunnel, tunnelIndex) => { + const status = getTunnelStatus(tunnelIndex); + const statusDisplay = getTunnelStatusDisplay(status); + const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`; + const isActionLoading = tunnelActions[tunnelName]; + const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED'; + const isConnected = statusValue === 'CONNECTED'; + const isConnecting = statusValue === 'CONNECTING'; + const isDisconnecting = statusValue === 'DISCONNECTING'; + const isRetrying = statusValue === 'RETRYING'; + const isWaiting = statusValue === 'WAITING'; + + return ( +
+ {/* Tunnel Header */} +
+
+ + {statusDisplay.icon} + +
+
+ Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} +
+
+ {statusDisplay.text} +
+
+
+
+ {tunnel.autoStart && ( + + + Auto + + )} + {/* Action Button */} + {!isActionLoading && ( + <> + {isConnected ? ( + + ) : isRetrying || isWaiting ? ( + + ) : ( + + )} + + )} + {isActionLoading && ( + + )} +
+
+ + {/* Error/Status Reason */} + {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( +
+
Error:
+ {status.reason} + {status.reason && status.reason.includes('Max retries exhausted') && ( + <> +
+ Check your Docker logs for the error reason, join the Discord or create a GitHub issue for help. +
+ + )} +
+ )} + + {/* Retry Info */} + {(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && ( +
+
+ {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'} +
+
+ Attempt {status.retryCount} of {status.maxRetries} + {status.nextRetryIn && ( + • Next retry in {status.nextRetryIn} seconds + )} +
+
)}
-
- ))} + ); + })}
) : ( -

No tunnel connections configured

+
+ +

No tunnel connections configured

+
)}
- - {/* Error/Status Reason */} - {((connectionState === "FAILED" || connectionState === "UNSTABLE") && statusReason) && ( -
- {statusReason} - {statusReason && statusReason.includes('Max retries exhausted') && ( - <> -
- - Check your Docker logs for the error reason, join the Discord or create a GitHub issue for help. - - - )} -
- )} - - {/* Retry Info */} - {(connectionState === "retrying" || connectionState === "waiting") && statusRetryCount && statusMaxRetries && ( -
- {connectionState === "waiting" ? "Waiting" : "Retry"} {statusRetryCount}/{statusMaxRetries} - {statusNextRetryIn && ( - • Next retry in {statusNextRetryIn}s - )} -
- )} - - {/* Action Buttons */} -
- - -
); diff --git a/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx b/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx index 0f7516f6..e1d5ed55 100644 --- a/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx @@ -5,6 +5,15 @@ import { Separator } from "@/components/ui/separator.tsx"; import { Input } from "@/components/ui/input.tsx"; import { Search } from "lucide-react"; +interface TunnelConnection { + sourcePort: number; + endpointPort: number; + endpointHost: string; + maxRetries: number; + retryInterval: number; + autoStart: boolean; +} + interface SSHHost { id: number; name: string; @@ -19,33 +28,33 @@ interface SSHHost { enableTunnel: boolean; enableConfigEditor: boolean; defaultPath: string; - tunnelConnections: any[]; + tunnelConnections: TunnelConnection[]; createdAt: string; updatedAt: string; } -interface HostStatus { - connectionState?: string; - statusReason?: string; - statusErrorType?: string; - statusRetryCount?: number; - statusMaxRetries?: number; - statusNextRetryIn?: number; - statusRetryExhausted?: boolean; +interface TunnelStatus { + status: string; + reason?: string; + errorType?: string; + retryCount?: number; + maxRetries?: number; + nextRetryIn?: number; + retryExhausted?: boolean; } interface SSHTunnelViewerProps { hosts: SSHHost[]; - hostStatuses?: Record; - onConnect?: (hostId: number) => void; - onDisconnect?: (hostId: number) => void; + tunnelStatuses: Record; + tunnelActions: Record; + onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise; } export function SSHTunnelViewer({ hosts = [], - hostStatuses = {}, - onConnect, - onDisconnect + tunnelStatuses = {}, + tunnelActions = {}, + onTunnelAction }: SSHTunnelViewerProps): React.ReactElement { const [searchQuery, setSearchQuery] = React.useState(""); const [debouncedSearch, setDebouncedSearch] = React.useState(""); @@ -56,14 +65,6 @@ export function SSHTunnelViewer({ return () => clearTimeout(handler); }, [searchQuery]); - const handleConnect = (hostId: number) => { - onConnect?.(hostId); - }; - - const handleDisconnect = (hostId: number) => { - onDisconnect?.(hostId); - }; - // Filter hosts by search query const filteredHosts = React.useMemo(() => { if (!debouncedSearch.trim()) return hosts; @@ -169,15 +170,9 @@ export function SSHTunnelViewer({
handleConnect(host.id)} - onDisconnect={() => handleDisconnect(host.id)} + tunnelStatuses={tunnelStatuses} + tunnelActions={tunnelActions} + onTunnelAction={onTunnelAction} />
))} diff --git a/src/apps/SSH/ssh-axios.ts b/src/apps/SSH/ssh-axios.ts index ebcda5e5..31178126 100644 --- a/src/apps/SSH/ssh-axios.ts +++ b/src/apps/SSH/ssh-axios.ts @@ -280,7 +280,7 @@ export async function getSSHHostById(hostId: number): Promise { // Tunnel-related functions -// Get tunnel statuses +// Get all tunnel statuses (per-tunnel) export async function getTunnelStatuses(): Promise> { try { // Determine the tunnel API URL based on environment @@ -293,7 +293,13 @@ export async function getTunnelStatuses(): Promise> } } -// Connect tunnel +// Get status for a specific tunnel by tunnel name +export async function getTunnelStatusByName(tunnelName: string): Promise { + const statuses = await getTunnelStatuses(); + return statuses[tunnelName]; +} + +// Connect tunnel (per-tunnel) export async function connectTunnel(tunnelConfig: TunnelConfig): Promise { try { // Determine the tunnel API URL based on environment @@ -306,7 +312,7 @@ export async function connectTunnel(tunnelConfig: TunnelConfig): Promise { } } -// Disconnect tunnel +// Disconnect tunnel (per-tunnel) export async function disconnectTunnel(tunnelName: string): Promise { try { // Determine the tunnel API URL based on environment @@ -319,4 +325,16 @@ export async function disconnectTunnel(tunnelName: string): Promise { } } +export async function cancelTunnel(tunnelName: string): Promise { + try { + // Determine the tunnel API URL based on environment + const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`; + const response = await tunnelApi.post(tunnelUrl, { tunnelName }); + return response.data; + } catch (error) { + console.error('Error canceling tunnel:', error); + throw error; + } +} + export { api }; \ No newline at end of file diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 4de08d95..a44a9643 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -83,6 +83,37 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) { } } +// Helper to check if request is from localhost +function isLocalhost(req: Request) { + const ip = req.ip || req.connection?.remoteAddress; + return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; +} + +// Internal-only endpoint for autostart (no JWT) +router.get('/host/internal', async (req: Request, res: Response) => { + if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') { + logger.warn('Unauthorized attempt to access internal SSH host endpoint'); + return res.status(403).json({ error: 'Forbidden' }); + } + try { + const data = await db.select().from(sshData); + // Convert tags to array, booleans to bool, tunnelConnections to array + const result = data.map((row: any) => ({ + ...row, + tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], + pin: !!row.pin, + enableTerminal: !!row.enableTerminal, + enableTunnel: !!row.enableTunnel, + tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [], + enableConfigEditor: !!row.enableConfigEditor, + })); + res.json(result); + } catch (err) { + logger.error('Failed to fetch SSH data (internal)', err); + res.status(500).json({ error: 'Failed to fetch SSH data' }); + } +}); + // Route: Create SSH data (requires JWT) // POST /ssh/host router.post('/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { diff --git a/src/backend/ssh_tunnel/ssh_tunnel.ts b/src/backend/ssh_tunnel/ssh_tunnel.ts index 5ab0c868..f0039775 100644 --- a/src/backend/ssh_tunnel/ssh_tunnel.ts +++ b/src/backend/ssh_tunnel/ssh_tunnel.ts @@ -1,22 +1,15 @@ import express from 'express'; import cors from 'cors'; import {Client} from 'ssh2'; -import {exec, spawn, ChildProcess} from 'child_process'; +import {ChildProcess} from 'child_process'; import chalk from 'chalk'; import axios from 'axios'; import * as net from 'net'; const app = express(); app.use(cors({ - origin: [ - 'http://localhost:5173', // Vite dev server - 'http://localhost:3000', // Common React dev port - 'http://127.0.0.1:5173', - 'http://127.0.0.1:3000', - '*', // Allow all for dev, remove in prod - ], - credentials: true, - methods: 'GET,POST,PUT,DELETE,OPTIONS', + origin: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: 'Origin,X-Requested-With,Content-Type,Accept,Authorization', })); app.use(express.json()); @@ -58,7 +51,6 @@ const activeRetryTimers = new Map(); // tunnelName -> re const countdownIntervals = new Map(); // tunnelName -> countdown interval const retryExhaustedTunnels = new Set(); // tunnelNames const remoteClosureEvents = new Map(); // tunnelName -> count -const hostConfigs = new Map(); // hostName -> hostConfig const tunnelConfigs = new Map(); // tunnelName -> tunnelConfig const activeTunnelProcesses = new Map(); // tunnelName -> ChildProcess @@ -227,15 +219,29 @@ function classifyError(errorMessage: string): ErrorType { return ERROR_TYPES.UNKNOWN; } +// Helper to build a unique marker for each tunnel +function getTunnelMarker(tunnelName: string) { + return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`; +} + // Cleanup and disconnect functions function cleanupTunnelResources(tunnelName: string): void { - // Kill any local ssh process for this tunnel + // Fire-and-forget remote pkill (do not block local cleanup) + const tunnelConfig = tunnelConfigs.get(tunnelName); + if (tunnelConfig) { + killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => { + if (err) { + logger.error(`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`); + } + }); + } + + // Local cleanup (always run immediately) if (activeTunnelProcesses.has(tunnelName)) { try { const proc = activeTunnelProcesses.get(tunnelName); if (proc) { proc.kill('SIGTERM'); - logger.info(`Killed local ssh process for tunnel '${tunnelName}' (pid: ${proc.pid})`); } } catch (e) { logger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e); @@ -248,10 +254,6 @@ function cleanupTunnelResources(tunnelName: string): void { const conn = activeTunnels.get(tunnelName); if (conn) { conn.end(); - logger.info(`Called conn.end() for tunnel '${tunnelName}'`); - conn.on('close', () => { - logger.info(`SSH2 Client connection closed for tunnel '${tunnelName}'`); - }); } } catch (e) { logger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e); @@ -711,6 +713,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void // Main SSH tunnel connection function function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { const tunnelName = tunnelConfig.name; + const tunnelMarker = getTunnelMarker(tunnelName); if (manualDisconnects.has(tunnelName)) { return; @@ -728,7 +731,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { // Only set status to CONNECTING if we're not already in WAITING state const currentStatus = connectionStatus.get(tunnelName); - if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { broadcastTunnelStatus(tunnelName, { connected: false, @@ -841,20 +843,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) { // For SSH key authentication, we need to create a temporary key file const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`; - tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -4 -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`; + tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`; } else { - tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -4 -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`; + tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`; } - conn.exec(tunnelCmd, (err, stream) => { if (err) { logger.error(`Connection error for '${tunnelName}': ${err.message}`); - try { - conn.end(); - } catch (e) { - } + conn.end(); activeTunnels.delete(tunnelName); @@ -1029,7 +1027,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { }; if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { - // Validate SSH key format if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) { logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`); @@ -1061,7 +1058,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { connOptions.password = tunnelConfig.sourcePassword; } - // Test basic network connectivity first const testSocket = new net.Socket(); testSocket.setTimeout(5000); @@ -1104,6 +1100,87 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP); } +// Add a helper to kill the tunnel by marker +function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) { + const tunnelMarker = getTunnelMarker(tunnelName); + const conn = new Client(); + const connOptions: any = { + host: tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + username: tunnelConfig.sourceUsername, + keepaliveInterval: 5000, + keepaliveCountMax: 10, + readyTimeout: 10000, + tcpKeepAlive: true, + algorithms: { + kex: [ + 'diffie-hellman-group14-sha256', + 'diffie-hellman-group14-sha1', + 'diffie-hellman-group1-sha1', + 'diffie-hellman-group-exchange-sha256', + 'diffie-hellman-group-exchange-sha1', + 'ecdh-sha2-nistp256', + 'ecdh-sha2-nistp384', + 'ecdh-sha2-nistp521' + ], + cipher: [ + 'aes128-ctr', + 'aes192-ctr', + 'aes256-ctr', + 'aes128-gcm@openssh.com', + 'aes256-gcm@openssh.com', + 'aes128-cbc', + 'aes192-cbc', + 'aes256-cbc', + '3des-cbc' + ], + hmac: [ + 'hmac-sha2-256', + 'hmac-sha2-512', + 'hmac-sha1', + 'hmac-md5' + ], + compress: [ + 'none', + 'zlib@openssh.com', + 'zlib' + ] + } + }; + if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { + connOptions.privateKey = tunnelConfig.sourceSSHKey; + if (tunnelConfig.sourceKeyPassword) { + connOptions.passphrase = tunnelConfig.sourceKeyPassword; + } + if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') { + connOptions.privateKeyType = tunnelConfig.sourceKeyType; + } + } else { + connOptions.password = tunnelConfig.sourcePassword; + } + conn.on('ready', () => { + // Use pkill to kill the tunnel by marker + const killCmd = `pkill -f '${tunnelMarker}'`; + conn.exec(killCmd, (err, stream) => { + if (err) { + conn.end(); + callback(err); + return; + } + stream.on('close', () => { + conn.end(); + callback(); + }); + stream.on('data', () => {}); + stream.stderr.on('data', () => {}); + }); + }); + conn.on('error', (err) => { + callback(err); + }); + conn.connect(connOptions); +} + // Express API endpoints app.get('/status', (req, res) => { res.json(getAllTunnelStatus()); @@ -1177,11 +1254,51 @@ app.post('/disconnect', (req, res) => { res.json({message: 'Disconnect request received', tunnelName}); }); +app.post('/cancel', (req, res) => { + const {tunnelName} = req.body; + + if (!tunnelName) { + return res.status(400).json({error: 'Tunnel name required'}); + } + + // Cancel retry operations + 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); + } + + // Set status to disconnected + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.DISCONNECTED, + manualDisconnect: true + }); + + // Clean up any existing tunnel resources + const tunnelConfig = tunnelConfigs.get(tunnelName) || null; + handleDisconnect(tunnelName, tunnelConfig, false); + + // Clear manual disconnect flag after a delay + setTimeout(() => { + manualDisconnects.delete(tunnelName); + }, 5000); + + res.json({message: 'Cancel request received', tunnelName}); +}); + // Auto-start functionality async function initializeAutoStartTunnels(): Promise { try { - // Fetch hosts with auto-start tunnel connections - const response = await axios.get('http://localhost:8081/ssh/host', { + // Fetch hosts with auto-start tunnel connections from the new internal endpoint + const response = await axios.get('http://localhost:8081/ssh/host/internal', { headers: { 'Content-Type': 'application/json', 'X-Internal-Request': '1' @@ -1225,7 +1342,7 @@ async function initializeAutoStartTunnels(): Promise { sourcePort: tunnelConnection.sourcePort, endpointPort: tunnelConnection.endpointPort, maxRetries: tunnelConnection.maxRetries, - retryInterval: tunnelConnection.retryInterval * 1000, // Convert to milliseconds + retryInterval: tunnelConnection.retryInterval * 1000, autoStart: tunnelConnection.autoStart, isPinned: host.pin }; @@ -1249,72 +1366,10 @@ async function initializeAutoStartTunnels(): Promise { }, 1000); } } catch (error: any) { - if (error.response?.status === 401) { - logger.warn('Authentication required for auto-start tunnels. Skipping auto-start initialization.'); - } else { - logger.error('Failed to initialize auto-start tunnels:', error.message); - } + logger.error('Failed to initialize auto-start tunnels:', error.message); } } -// Health check endpoint -app.get('/health', (req, res) => { - res.json({ - status: 'healthy', - activeTunnels: activeTunnels.size, - timestamp: new Date().toISOString() - }); -}); - -// Get all tunnel configurations -app.get('/tunnels', (req, res) => { - const tunnels = Array.from(tunnelConfigs.values()); - res.json(tunnels); -}); - -// Update tunnel configuration -app.put('/tunnel/:name', (req, res) => { - const {name} = req.params; - const tunnelConfig: TunnelConfig = req.body; - - if (!tunnelConfig || !tunnelConfig.name) { - return res.status(400).json({error: 'Invalid tunnel configuration'}); - } - - tunnelConfigs.set(name, tunnelConfig); - - // If tunnel is currently connected, disconnect and reconnect with new config - if (activeTunnels.has(name)) { - manualDisconnects.add(name); - handleDisconnect(name, tunnelConfig, false); - - setTimeout(() => { - manualDisconnects.delete(name); - connectSSHTunnel(tunnelConfig, 0); - }, 2000); - } - - res.json({message: 'Tunnel configuration updated', name}); -}); - -// Delete tunnel configuration -app.delete('/tunnel/:name', (req, res) => { - const {name} = req.params; - - // Disconnect if active - if (activeTunnels.has(name)) { - manualDisconnects.add(name); - const tunnelConfig = tunnelConfigs.get(name) || null; - handleDisconnect(name, tunnelConfig, false); - } - - // Remove from configurations - tunnelConfigs.delete(name); - - res.json({message: 'Tunnel deleted', name}); -}); - -// Start the server const PORT = 8083; app.listen(PORT, () => { setTimeout(() => {