diff --git a/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx b/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx index 8ccc5b71..0cdaac43 100644 --- a/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx +++ b/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx @@ -471,7 +471,9 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( filteredSshByFolder[folder] = hosts.filter(conn => { const q = debouncedSearch.trim().toLowerCase(); if (!q) return true; - return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q); + return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) || + (conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) || + (conn.tags || '').toLowerCase().includes(q); }); }); @@ -926,7 +928,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( setSearch(e.target.value)} - placeholder="Search hosts..." + placeholder="Search hosts by name, username, IP, folder, tags..." className="w-full h-8 text-sm bg-background border border-border rounded" autoComplete="off" /> diff --git a/src/apps/SSH/Terminal/SSHSidebar.tsx b/src/apps/SSH/Terminal/SSHSidebar.tsx index 173bb686..356f0813 100644 --- a/src/apps/SSH/Terminal/SSHSidebar.tsx +++ b/src/apps/SSH/Terminal/SSHSidebar.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { CornerDownLeft, - Hammer + Hammer, Pin } from "lucide-react" import { @@ -131,10 +131,16 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT if (!debouncedSearch.trim()) return hosts; const q = debouncedSearch.trim().toLowerCase(); return hosts.filter(h => { - const name = (h.name || "").toLowerCase(); - const ip = (h.ip || "").toLowerCase(); - const tags = Array.isArray(h.tags) ? h.tags : []; - return name.includes(q) || ip.includes(q) || tags.some((tag: string) => tag.toLowerCase().includes(q)); + const searchableText = [ + h.name || '', + h.username, + h.ip, + h.folder || '', + ...(h.tags || []), + h.authType, + h.defaultPath || '' + ].join(' ').toLowerCase(); + return searchableText.includes(q); }); }, [hosts, debouncedSearch]); @@ -214,7 +220,7 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT setSearch(e.target.value)} - placeholder="Search hosts..." + placeholder="Search hosts by name, username, IP, folder, tags..." className="w-full h-8 text-sm bg-background border border-border rounded" autoComplete="off" /> @@ -343,7 +349,9 @@ const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect }: { onClick={() => onHostConnect(host)} >
- {host.pin && } + {host.pin && + + } {host.name || host.ip}
diff --git a/src/apps/SSH/Tunnel/SSHTunnel.tsx b/src/apps/SSH/Tunnel/SSHTunnel.tsx index 1a54c7f1..325839d7 100644 --- a/src/apps/SSH/Tunnel/SSHTunnel.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnel.tsx @@ -1,90 +1,75 @@ 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 } from "@/apps/SSH/ssh-axios"; import axios from "axios"; interface ConfigEditorProps { onSelectView: (view: string) => void; } -interface SSHTunnel { - id: number; - name: string; - folder: string; +interface TunnelConnection { sourcePort: number; endpointPort: number; - sourceIP: string; - sourceSSHPort: number; - sourceUsername: string; - sourcePassword: string; - sourceAuthMethod: string; - sourceSSHKey: string; - sourceKeyPassword: string; - sourceKeyType: string; - endpointIP: string; - endpointSSHPort: number; - endpointUsername: string; - endpointPassword: string; - endpointAuthMethod: string; - endpointSSHKey: string; - endpointKeyPassword: string; - endpointKeyType: string; + endpointHost: string; maxRetries: number; retryInterval: number; - connectionState: string; autoStart: boolean; - isPinned: boolean; +} + +interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableConfigEditor: boolean; + defaultPath: string; + 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; } export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement { - const [tunnels, setTunnels] = useState([]); - const [tunnelsLoading, setTunnelsLoading] = useState(false); - const [tunnelsError, setTunnelsError] = useState(null); - const [tunnelStatusMap, setTunnelStatusMap] = useState>({}); - const sidebarRef = React.useRef(null); + const [hosts, setHosts] = useState([]); + const [hostStatuses, setHostStatuses] = useState>({}); - const fetchTunnels = useCallback(async () => { - setTunnelsLoading(true); - setTunnelsError(null); + const fetchHosts = useCallback(async () => { try { - const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; - const res = await axios.get( - (window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + '/ssh_tunnel/tunnel', - { headers: { Authorization: `Bearer ${jwt}` } } - ); - const tunnelData = res.data || []; - setTunnels(tunnelData.map((tunnel: any) => ({ - id: tunnel.id, - name: tunnel.name, - folder: tunnel.folder || '', - sourcePort: tunnel.sourcePort, - endpointPort: tunnel.endpointPort, - sourceIP: tunnel.sourceIP, - sourceSSHPort: tunnel.sourceSSHPort, - sourceUsername: tunnel.sourceUsername || '', - sourcePassword: tunnel.sourcePassword || '', - sourceAuthMethod: tunnel.sourceAuthMethod || 'password', - sourceSSHKey: tunnel.sourceSSHKey || '', - sourceKeyPassword: tunnel.sourceKeyPassword || '', - sourceKeyType: tunnel.sourceKeyType || '', - endpointIP: tunnel.endpointIP, - endpointSSHPort: tunnel.endpointSSHPort, - endpointUsername: tunnel.endpointUsername || '', - endpointPassword: tunnel.endpointPassword || '', - endpointAuthMethod: tunnel.endpointAuthMethod || 'password', - endpointSSHKey: tunnel.endpointSSHKey || '', - endpointKeyPassword: tunnel.endpointKeyPassword || '', - endpointKeyType: tunnel.endpointKeyType || '', - maxRetries: tunnel.maxRetries || 3, - retryInterval: tunnel.retryInterval || 5000, - connectionState: tunnel.connectionState || 'DISCONNECTED', - autoStart: tunnel.autoStart || false, - isPinned: tunnel.isPinned || false - }))); - } catch (err: any) { - setTunnelsError('Failed to load tunnels'); - } finally { - setTunnelsLoading(false); + const hostsData = await getSSHHosts(); + setHosts(hostsData); + } catch (err) { + // Silent error handling } }, []); @@ -92,17 +77,75 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme const fetchTunnelStatuses = useCallback(async () => { try { const res = await axios.get('http://localhost:8083/status'); - setTunnelStatusMap(res.data || {}); + const statusData = res.data || {}; + + // 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) { + // Determine overall host status based on tunnel statuses + const connectedTunnels = hostTunnelStatuses.filter(s => s.status === 'connected'); + const failedTunnels = hostTunnelStatuses.filter(s => s.status === 'failed'); + const connectingTunnels = hostTunnelStatuses.filter(s => + ['connecting', 'verifying', 'retrying'].includes(s.status) + ); + + let overallStatus: string; + let statusReason: string | undefined; + + if (connectingTunnels.length > 0) { + overallStatus = 'connecting'; + } else if (failedTunnels.length === hostTunnelStatuses.length) { + overallStatus = 'failed'; + statusReason = failedTunnels[0]?.reason; + } else if (connectedTunnels.length === hostTunnelStatuses.length) { + overallStatus = 'connected'; + } else if (connectedTunnels.length > 0) { + overallStatus = 'connected'; + } else { + overallStatus = 'disconnected'; + } + + newHostStatuses[host.id] = { + connectionState: overallStatus, + statusReason, + statusErrorType: failedTunnels[0]?.errorType, + statusRetryCount: connectingTunnels.find(s => s.status === 'retrying')?.retryCount, + statusMaxRetries: connectingTunnels.find(s => s.status === 'retrying')?.maxRetries, + statusNextRetryIn: connectingTunnels.find(s => s.status === 'retrying')?.nextRetryIn, + statusRetryExhausted: failedTunnels.some(s => s.retryExhausted), + }; + } else { + // Set default disconnected status + newHostStatuses[host.id] = { + connectionState: 'disconnected' + }; + } + }); + + setHostStatuses(newHostStatuses); } catch (err) { - // Optionally handle error + // Silent error handling } - }, []); + }, [hosts]); useEffect(() => { - fetchTunnels(); - const interval = setInterval(fetchTunnels, 10000); + fetchHosts(); + const interval = setInterval(fetchHosts, 10000); return () => clearInterval(interval); - }, [fetchTunnels]); + }, [fetchHosts]); useEffect(() => { fetchTunnelStatuses(); @@ -110,94 +153,88 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme return () => clearInterval(interval); }, [fetchTunnelStatuses]); - // Merge backend status into tunnels - const tunnelsWithStatus = tunnels.map(tunnel => { - const status = tunnelStatusMap[tunnel.name] || {}; - return { - ...tunnel, - connectionState: status.status ? status.status.toUpperCase() : tunnel.connectionState, - statusReason: status.reason || '', - statusErrorType: status.errorType || '', - statusManualDisconnect: status.manualDisconnect || false, - statusRetryCount: status.retryCount, - statusMaxRetries: status.maxRetries, - statusNextRetryIn: status.nextRetryIn, - statusRetryExhausted: status.retryExhausted, - }; - }); + const handleConnect = async (hostId: number) => { + const host = hosts.find(h => h.id === hostId); + if (!host || !host.tunnelConnections || host.tunnelConnections.length === 0) { + return; + } - const handleConnect = async (tunnelId: string) => { // Immediately set to CONNECTING for instant UI feedback - setTunnels(prev => prev.map(t => - t.id.toString() === tunnelId - ? { ...t, connectionState: "CONNECTING" } - : t - )); - const tunnel = tunnels.find(t => t.id.toString() === tunnelId); - if (!tunnel) return; + setHostStatuses(prev => ({ + ...prev, + [hostId]: { ...prev[hostId], connectionState: "connecting" } + })); + try { - await axios.post('http://localhost:8083/connect', { - ...tunnel, - name: tunnel.name - }); - // No need to update state here; polling will update real status + // For each tunnel connection, create a tunnel configuration + for (const tunnelConnection of host.tunnelConnections) { + // Find the endpoint host configuration + const endpointHost = hosts.find(h => + h.name === tunnelConnection.endpointHost || + `${h.username}@${h.ip}` === tunnelConnection.endpointHost + ); + + if (!endpointHost) { + continue; + } + + // Create tunnel configuration + const tunnelConfig = { + name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`, + hostName: host.name || `${host.username}@${host.ip}`, + sourceIP: host.ip, + sourceSSHPort: host.port, + sourceUsername: host.username, + sourcePassword: host.password, + sourceAuthMethod: host.authType, + sourceSSHKey: host.key, + sourceKeyPassword: host.keyPassword, + sourceKeyType: host.keyType, + endpointIP: endpointHost.ip, + endpointSSHPort: endpointHost.port, + endpointUsername: endpointHost.username, + endpointPassword: endpointHost.password, + 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, + isPinned: host.pin + }; + + await axios.post('http://localhost:8083/connect', tunnelConfig); + } } catch (err) { - // Optionally handle error + // Reset status on error + setHostStatuses(prev => ({ + ...prev, + [hostId]: { ...prev[hostId], connectionState: "failed", statusReason: "Failed to connect" } + })); } }; - const handleDisconnect = async (tunnelId: string) => { + const handleDisconnect = async (hostId: number) => { + const host = hosts.find(h => h.id === hostId); + if (!host) return; + // Immediately set to DISCONNECTING for instant UI feedback - setTunnels(prev => prev.map(t => - t.id.toString() === tunnelId - ? { ...t, connectionState: "DISCONNECTING" } - : t - )); - const tunnel = tunnels.find(t => t.id.toString() === tunnelId); - if (!tunnel) return; + setHostStatuses(prev => ({ + ...prev, + [hostId]: { ...prev[hostId], connectionState: "disconnecting" } + })); + try { - await axios.post('http://localhost:8083/disconnect', { - tunnelName: tunnel.name - }); - // No need to update state here; polling will update real status + // Disconnect all tunnels for this host + for (const tunnelConnection of host.tunnelConnections) { + const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`; + await axios.post('http://localhost:8083/disconnect', { tunnelName }); + } } catch (err) { - // Optionally handle error - } - }; - - const handleDeleteTunnel = async (tunnelId: string) => { - try { - const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; - await axios.delete( - (window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`, - { headers: { Authorization: `Bearer ${jwt}` } } - ); - fetchTunnels(); - } catch (err: any) { - console.error('Failed to delete tunnel:', err); - } - }; - - const handleEditTunnel = async (tunnelId: string, data: any) => { - try { - const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; - await axios.put( - (window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`, - data, - { headers: { Authorization: `Bearer ${jwt}` } } - ); - fetchTunnels(); - } catch (err: any) { - console.error('Failed to edit tunnel:', err); - } - }; - - const handleEditTunnelClick = (tunnelId: string) => { - // Find the tunnel data and pass it to the sidebar - const tunnel = tunnels.find(t => t.id.toString() === tunnelId); - if (tunnel && sidebarRef.current) { - // Call the sidebar's openEditSheet function - sidebarRef.current.openEditSheet(tunnel); + // Silent error handling } }; @@ -205,19 +242,15 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
diff --git a/src/apps/SSH/Tunnel/SSHTunnelObject.tsx b/src/apps/SSH/Tunnel/SSHTunnelObject.tsx index d11c4170..1308a2bf 100644 --- a/src/apps/SSH/Tunnel/SSHTunnelObject.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnelObject.tsx @@ -2,7 +2,8 @@ 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, Edit, Trash2 } from "lucide-react"; +import { Loader2, Pin, Terminal, Network, FileEdit, Tag } from "lucide-react"; +import { Badge } from "@/components/ui/badge.tsx"; const CONNECTION_STATES = { DISCONNECTED: "disconnected", @@ -15,27 +16,62 @@ const CONNECTION_STATES = { DISCONNECTING: "disconnecting" }; +interface TunnelConnection { + sourcePort: number; + endpointPort: number; + endpointHost: string; + maxRetries: number; + retryInterval: number; + autoStart: boolean; +} + +interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableConfigEditor: boolean; + defaultPath: string; + tunnelConnections: TunnelConnection[]; + createdAt: string; + updatedAt: string; +} + interface SSHTunnelObjectProps { - hostConfig: any; - onConnect?: () => void; - onDisconnect?: () => void; - onDelete?: () => void; - onEdit?: () => void; - connectionState?: keyof typeof CONNECTION_STATES; - isPinned?: boolean; + 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; } export function SSHTunnelObject({ - hostConfig = {}, + host, onConnect, onDisconnect, - onDelete, - onEdit, connectionState = "DISCONNECTED", - isPinned = false + statusReason, + statusErrorType, + statusRetryCount, + statusMaxRetries, + statusNextRetryIn, + statusRetryExhausted }: SSHTunnelObjectProps): React.ReactElement { - const getStatusColor = (state: keyof typeof CONNECTION_STATES) => { - switch (state) { + const getStatusColor = (state: string) => { + const upperState = state.toUpperCase(); + switch (upperState) { case "CONNECTED": return "bg-green-500"; case "CONNECTING": @@ -51,8 +87,9 @@ export function SSHTunnelObject({ } }; - const getStatusText = (state: keyof typeof CONNECTION_STATES) => { - switch (state) { + const getStatusText = (state: string) => { + const upperState = state.toUpperCase(); + switch (upperState) { case "CONNECTED": return "Connected"; case "CONNECTING": @@ -70,43 +107,26 @@ export function SSHTunnelObject({ } }; - - - const isConnected = connectionState === "CONNECTED"; - const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING"].includes(connectionState); - const isDisconnecting = connectionState === "DISCONNECTING"; + const isConnected = connectionState === "CONNECTED" || connectionState === "connected"; + const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING", "connecting", "verifying", "retrying"].includes(connectionState); + const isDisconnecting = connectionState === "DISCONNECTING" || connectionState === "disconnecting"; return ( - {/* Hover overlay buttons */} -
- - -
- -
-
-
- - {isPinned && } - {hostConfig.name || "My SSH Tunnel"} - +
+ {/* Host Header */} +
+
+ {host.pin && } +
+

+ {host.name || `${host.username}@${host.ip}`} +

+

+ {host.ip}:{host.port} • {host.username} +

+
-
@@ -115,28 +135,53 @@ export function SSHTunnelObject({
- -
-
- Source: - - {hostConfig.source || "localhost:22"} - -
-
- Endpoint: - - {hostConfig.endpoint || "test:224"} - + {/* Tags */} + {host.tags && host.tags.length > 0 && ( +
+ {host.tags.slice(0, 3).map((tag, index) => ( + + + {tag} + + ))} + {host.tags.length > 3 && ( + + +{host.tags.length - 3} + + )}
+ )} + + + + {/* Tunnel Connections */} +
+

Tunnel Connections

+ {host.tunnelConnections && host.tunnelConnections.length > 0 ? ( +
+ {host.tunnelConnections.map((tunnel, index) => ( +
+
+ Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} + {tunnel.autoStart && ( + + Auto + + )} +
+
+ ))} +
+ ) : ( +

No tunnel connections configured

+ )}
- {/* Error/Status Reason */} - {((connectionState === "FAILED" || connectionState === "UNSTABLE") && hostConfig.statusReason) && ( + {((connectionState === "FAILED" || connectionState === "UNSTABLE") && statusReason) && (
- {hostConfig.statusReason} - {typeof hostConfig.statusReason === 'string' && hostConfig.statusReason.includes('Max retries exhausted') && ( + {statusReason} + {statusReason && statusReason.includes('Max retries exhausted') && ( <>
@@ -146,9 +191,21 @@ export function SSHTunnelObject({ )}
)} -
+ + {/* Retry Info */} + {connectionState === "RETRYING" && statusRetryCount && statusMaxRetries && ( +
+ Retry {statusRetryCount}/{statusMaxRetries} + {statusNextRetryIn && ( + • Next retry in {statusNextRetryIn}s + )} +
+ )} + + {/* Action Buttons */} +
- - { if (!submitting) setSheetOpen(open); }}> - - - - - - Add SSH Tunnel - - Create a new SSH tunnel connection. - - - -
- {submitError && ( -
{submitError}
- )} -
- - {/* Tunnel Name */} -
-

Tunnel Name

- ( - - - - - - - )} - /> -
- - {/* Folder */} - ( - - Folder - - { - if (typeof field.ref === 'function') field.ref(el); - (folderInputRef as React.MutableRefObject).current = el; - }} - placeholder="e.g. Work" - autoComplete="off" - value={field.value} - onFocus={() => setFolderDropdownOpen(true)} - onChange={e => { - field.onChange(e); - setFolderDropdownOpen(true); - }} - disabled={foldersLoading} - /> - - {/* Folder dropdown menu */} - {folderDropdownOpen && filteredFolders.length > 0 && ( -
-
- {filteredFolders.map((folder) => ( - - ))} -
-
- )} - {foldersLoading &&
Loading folders...
} - {foldersError &&
{foldersError}
} - -
- )} - /> - - {/* Tunnel Port Configuration */} -
-

Tunnel Port Configuration

-
- ( - - Source Port (Local) - - field.onChange(Number(e.target.value) || 22)} - /> - - - - )} - /> - - ( - - Endpoint Port (Remote) - - field.onChange(Number(e.target.value) || 224)} - /> - - - - )} - /> -
-

- This tunnel will forward traffic from port {sourcePort} on the source machine to port {endpointPort} on the endpoint machine. -

-
- - {/* SSH Pass Warning */} - - Sshpass Required For Password Authentication - - For password-based SSH authentication, sshpass must be installed on both the local and remote servers.
- Install with: sudo apt install sshpass (Debian/Ubuntu) or the equivalent for your OS. -
- Other installation methods -
    -
  • CentOS/RHEL/Fedora: sudo yum install sshpass or sudo dnf install sshpass
  • -
  • macOS: brew install hudochenkov/sshpass/sshpass
  • -
  • Windows: Use WSL or consider SSH key authentication
  • -
-
-
-
- {/* SSH Config Info */} - - SSH Server Configuration Required - - For reverse SSH tunnels, the endpoint SSH server must allow: -
    -
  • GatewayPorts yes (bind remote ports)
  • -
  • AllowTcpForwarding yes (port forwarding)
  • -
  • PermitRootLogin yes (if using root)
  • -
- Edit /etc/ssh/sshd_config and restart SSH: sudo systemctl restart sshd -
-
- - {/* Source SSH Configuration */} -
-

Source SSH Configuration (Local Machine)

- -
-
- ( - - Source IP - - - - - - )} - /> - ( - - Source SSH Port - - field.onChange(Number(e.target.value) || 22)} - /> - - - - )} - /> -
- ( - - Source Username - - - - - - )} - /> - ( - - - Password - SSH Key - - - - ( - - Source Password - - - - - - )} - /> - - - - ( - - SSH Private Key - -
- { - const file = e.target.files?.[0]; - field.onChange(file || null); - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
-
-
- )} - /> - ( - - Key Password (if protected) - - - - - - )} - /> - ( - - Key Type - -
- - {editSourceKeyTypeDropdownOpen && ( -
-
- {keyTypeOptions.map(opt => ( - - ))} -
-
- )} -
-
- -
- )} - /> -
-
- )} - /> -
-
- - {/* Endpoint SSH Configuration */} -
-

Endpoint SSH Configuration (Remote Machine)

- -
-
- ( - - Endpoint IP - - - - - - )} - /> - ( - - Endpoint SSH Port - - field.onChange(Number(e.target.value) || 22)} - /> - - - - )} - /> -
- ( - - Endpoint Username - - - - - - )} - /> - ( - - - Password - SSH Key - - - - ( - - Endpoint Password - - - - - - )} - /> - - - - ( - - SSH Private Key - -
- { - const file = e.target.files?.[0]; - field.onChange(file || null); - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
-
-
- )} - /> - ( - - Key Password (if protected) - - - - - - )} - /> - ( - - Key Type - -
- - {editEndpointKeyTypeDropdownOpen && ( -
-
- {keyTypeOptions.map(opt => ( - - ))} -
-
- )} -
-
- -
- )} - /> -
-
- )} - /> -
-
- - {/* Other */} -
-

Other

- -
- ( - - Max Retries - - field.onChange(Number(e.target.value) || 3)} - /> - - - - )} - /> - ( - - Retry Interval (ms) - - field.onChange(Number(e.target.value) || 5000)} - /> - - - - )} - /> - ( - - -
- - Auto Start on Container Launch -
-
- -
- )} - /> - ( - - -
- - Pin Connection -
-
- -
- )} - /> -
-
- - -
- - - - - - - - - - -
-
-
- - - {/* Edit Tunnel Sheet */} - { - if (!open) { - setTimeout(() => { - setEditTunnelData(null); - editTunnelForm.reset(); - }, 100); - } - setEditSheetOpen(open); - }}> - - - Edit SSH Tunnel - - Modify the SSH tunnel configuration. - - -
- {submitError && ( -
{submitError}
- )} -
- - {/* Tunnel Name */} -
-

Tunnel Name

- ( - - - - - - - )} - /> -
- - {/* Folder */} - ( - - Folder - - setEditFolderDropdownOpen(true)} - onChange={e => { - field.onChange(e); - setEditFolderDropdownOpen(true); - }} - disabled={foldersLoading} - /> - - {/* Folder dropdown menu */} - {editFolderDropdownOpen && filteredFolders.length > 0 && ( -
-
- {filteredFolders.map((folder) => ( - - ))} -
-
- )} - {foldersLoading &&
Loading folders...
} - {foldersError &&
{foldersError}
} - -
- )} - /> - - {/* Tunnel Port Configuration */} -
-

Tunnel Port Configuration

-
- ( - - Source Port (Local) - - field.onChange(Number(e.target.value) || 22)} - /> - - - - )} - /> - - ( - - Endpoint Port (Remote) - - field.onChange(Number(e.target.value) || 224)} - /> - - - - )} - /> -
-

- This tunnel will forward traffic from port {editTunnelForm.watch('sourcePort')} on the source machine to port {editTunnelForm.watch('endpointPort')} on the endpoint machine. -

-
- - {/* SSH Pass Warning */} - - Sshpass Required For Password Authentication - - For password-based SSH authentication, sshpass must be installed on both the local and remote servers.
- Install with: sudo apt install sshpass (Debian/Ubuntu) or the equivalent for your OS. -
- Other installation methods -
    -
  • CentOS/RHEL/Fedora: sudo yum install sshpass or sudo dnf install sshpass
  • -
  • macOS: brew install hudochenkov/sshpass/sshpass
  • -
  • Windows: Use WSL or consider SSH key authentication
  • -
-
-
-
- {/* SSH Config Info */} - - SSH Server Configuration Required - - For reverse SSH tunnels, the endpoint SSH server must allow: -
    -
  • GatewayPorts yes (bind remote ports)
  • -
  • AllowTcpForwarding yes (port forwarding)
  • -
  • PermitRootLogin yes (if using root)
  • -
- Edit /etc/ssh/sshd_config and restart SSH: sudo systemctl restart sshd -
-
- - {/* Source SSH Configuration */} -
-

Source SSH Configuration (Local Machine)

- -
-
- ( - - Source IP - - - - - - )} - /> - ( - - Source SSH Port - - field.onChange(Number(e.target.value) || 22)} - /> - - - - )} - /> -
- ( - - Source Username - - - - - - )} - /> - ( - - - Password - SSH Key - - - - ( - - Source Password - - - - - - )} - /> - - - - ( - - SSH Private Key - -
- { - const file = e.target.files?.[0]; - field.onChange(file || null); - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
-
-
- )} - /> - ( - - Key Password (if protected) - - - - - - )} - /> - ( - - Key Type - -
- - {sourceKeyTypeDropdownOpen && ( -
-
- {keyTypeOptions.map(opt => ( - - ))} -
-
- )} -
-
- -
- )} - /> -
-
- )} - /> -
-
- - {/* Endpoint SSH Configuration */} -
-

Endpoint SSH Configuration (Remote Machine)

- -
-
- ( - - Endpoint IP - - - - - - )} - /> - ( - - Endpoint SSH Port - - field.onChange(Number(e.target.value) || 22)} - /> - - - - )} - /> -
- ( - - Endpoint Username - - - - - - )} - /> - ( - - - Password - SSH Key - - - - ( - - Endpoint Password - - - - - - )} - /> - - - - ( - - SSH Private Key - -
- { - const file = e.target.files?.[0]; - field.onChange(file || null); - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
-
-
- )} - /> - ( - - Key Password (if protected) - - - - - - )} - /> - ( - - Key Type - -
- - {editEndpointKeyTypeDropdownOpen && ( -
-
- {keyTypeOptions.map(opt => ( - - ))} -
-
- )} -
-
- -
- )} - /> -
-
- )} - /> -
-
- - {/* Advanced Options */} -
-

Advanced Options

- -
- ( - - Max Retries - - field.onChange(Number(e.target.value) || 3)} - /> - - - - )} - /> - ( - - Retry Interval (ms) - - field.onChange(Number(e.target.value) || 5000)} - /> - - - - )} - /> - ( - - -
- - Auto Start on Container Launch -
-
- -
- )} - /> - ( - - -
- - Pin Connection -
-
- -
- )} - /> -
-
- - -
- - - - - - - - - -
-
- ); -}); \ No newline at end of file + ) +} \ No newline at end of file diff --git a/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx b/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx index 372dc94f..0f7516f6 100644 --- a/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx @@ -2,81 +2,120 @@ import React from "react"; import { SSHTunnelObject } from "./SSHTunnelObject.tsx"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx"; import { Separator } from "@/components/ui/separator.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Search } from "lucide-react"; + +interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableConfigEditor: boolean; + defaultPath: string; + tunnelConnections: any[]; + createdAt: string; + updatedAt: string; +} + +interface HostStatus { + connectionState?: string; + statusReason?: string; + statusErrorType?: string; + statusRetryCount?: number; + statusMaxRetries?: number; + statusNextRetryIn?: number; + statusRetryExhausted?: boolean; +} interface SSHTunnelViewerProps { - tunnels: Array<{ - id: number; - name: string; - folder: string; - sourcePort: number; - endpointPort: number; - sourceIP: string; - sourceSSHPort: number; - sourceUsername: string; - sourcePassword: string; - sourceAuthMethod: string; - sourceSSHKey: string; - sourceKeyPassword: string; - sourceKeyType: string; - endpointIP: string; - endpointSSHPort: number; - endpointUsername: string; - endpointPassword: string; - endpointAuthMethod: string; - endpointSSHKey: string; - endpointKeyPassword: string; - endpointKeyType: string; - maxRetries: number; - retryInterval: number; - connectionState?: string; - autoStart: boolean; - isPinned: boolean; - }>; - onConnect?: (tunnelId: string) => void; - onDisconnect?: (tunnelId: string) => void; - onDeleteTunnel?: (tunnelId: string) => void; - onEditTunnel?: (tunnelId: string) => void; + hosts: SSHHost[]; + hostStatuses?: Record; + onConnect?: (hostId: number) => void; + onDisconnect?: (hostId: number) => void; } export function SSHTunnelViewer({ - tunnels = [], - onConnect, - onDisconnect, - onDeleteTunnel, - onEditTunnel + hosts = [], + hostStatuses = {}, + onConnect, + onDisconnect }: SSHTunnelViewerProps): React.ReactElement { - const handleConnect = (tunnelId: string) => { - onConnect?.(tunnelId); + const [searchQuery, setSearchQuery] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + + // Debounce search + React.useEffect(() => { + const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200); + return () => clearTimeout(handler); + }, [searchQuery]); + + const handleConnect = (hostId: number) => { + onConnect?.(hostId); }; - const handleDisconnect = (tunnelId: string) => { - onDisconnect?.(tunnelId); + const handleDisconnect = (hostId: number) => { + onDisconnect?.(hostId); }; - // Group tunnels by folder and sort - const tunnelsByFolder = React.useMemo(() => { - const map: Record = {}; - tunnels.forEach(tunnel => { - const folder = tunnel.folder && tunnel.folder.trim() ? tunnel.folder : 'No Folder'; + // Filter hosts by search query + const filteredHosts = React.useMemo(() => { + if (!debouncedSearch.trim()) return hosts; + + const query = debouncedSearch.trim().toLowerCase(); + return hosts.filter(host => { + const searchableText = [ + host.name || '', + host.username, + host.ip, + host.folder || '', + ...(host.tags || []), + host.authType, + host.defaultPath || '' + ].join(' ').toLowerCase(); + return searchableText.includes(query); + }); + }, [hosts, debouncedSearch]); + + // Filter hosts to only show those with enableTunnel: true and tunnelConnections + const tunnelHosts = React.useMemo(() => { + return filteredHosts.filter(host => + host.enableTunnel && + host.tunnelConnections && + host.tunnelConnections.length > 0 + ); + }, [filteredHosts]); + + // Group hosts by folder and sort + const hostsByFolder = React.useMemo(() => { + const map: Record = {}; + tunnelHosts.forEach(host => { + const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized'; if (!map[folder]) map[folder] = []; - map[folder].push(tunnel); + map[folder].push(host); }); return map; - }, [tunnels]); + }, [tunnelHosts]); const sortedFolders = React.useMemo(() => { - const folders = Object.keys(tunnelsByFolder); + const folders = Object.keys(hostsByFolder); folders.sort((a, b) => { - if (a === 'No Folder') return -1; - if (b === 'No Folder') return 1; + if (a === 'Uncategorized') return -1; + if (b === 'Uncategorized') return 1; return a.localeCompare(b); }); return folders; - }, [tunnelsByFolder]); + }, [hostsByFolder]); - const getSortedTunnels = (arr: typeof tunnels) => { - const pinned = arr.filter(t => t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || '')); - const rest = arr.filter(t => !t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || '')); + const getSortedHosts = (arr: SSHHost[]) => { + const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); + const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); return [...pinned, ...rest]; }; @@ -93,14 +132,28 @@ export function SSHTunnelViewer({

+ {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ {/* Accordion Layout */} - {tunnels.length === 0 ? ( + {tunnelHosts.length === 0 ? (

No SSH Tunnels

- Create your first SSH tunnel to get started. Use the sidebar to add a new tunnel configuration. + {searchQuery.trim() ? + "No hosts match your search criteria." : + "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections." + }

) : ( @@ -112,16 +165,19 @@ export function SSHTunnelViewer({
- {getSortedTunnels(tunnelsByFolder[folder]).map((tunnel, tunnelIndex) => ( -
+ {getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => ( +
handleConnect(tunnel.id.toString())} - onDisconnect={() => handleDisconnect(tunnel.id.toString())} - onDelete={() => onDeleteTunnel?.(tunnel.id.toString())} - onEdit={() => onEditTunnel?.(tunnel.id.toString())} + host={host} + connectionState={hostStatuses[host.id]?.connectionState as any} + statusReason={hostStatuses[host.id]?.statusReason} + statusErrorType={hostStatuses[host.id]?.statusErrorType} + statusRetryCount={hostStatuses[host.id]?.statusRetryCount} + statusMaxRetries={hostStatuses[host.id]?.statusMaxRetries} + statusNextRetryIn={hostStatuses[host.id]?.statusNextRetryIn} + statusRetryExhausted={hostStatuses[host.id]?.statusRetryExhausted} + onConnect={() => handleConnect(host.id)} + onDisconnect={() => handleDisconnect(host.id)} />
))} diff --git a/src/backend/ssh_tunnel/ssh_tunnel.ts b/src/backend/ssh_tunnel/ssh_tunnel.ts index 96f607cb..ff8aa756 100644 --- a/src/backend/ssh_tunnel/ssh_tunnel.ts +++ b/src/backend/ssh_tunnel/ssh_tunnel.ts @@ -46,21 +46,55 @@ const logger = { } }; -// State management -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 retryExhaustedTunnels = new Set(); -const remoteClosureEvents = new Map(); -const hostConfigs = new Map(); +// State management for host-based tunnels +const activeTunnels = new Map(); // tunnelName -> Client +const retryCounters = new Map(); // tunnelName -> retryCount +const connectionStatus = new Map(); // tunnelName -> status +const tunnelVerifications = new Map(); // tunnelName -> verification +const manualDisconnects = new Set(); // tunnelNames +const verificationTimers = new Map(); // timer keys -> timeout +const activeRetryTimers = new Map(); // tunnelName -> retry timer +const retryExhaustedTunnels = new Set(); // tunnelNames +const remoteClosureEvents = new Map(); // tunnelName -> count +const hostConfigs = new Map(); // hostName -> hostConfig +const tunnelConfigs = new Map(); // tunnelName -> tunnelConfig // Types -interface HostConfig { +interface TunnelConnection { + sourcePort: number; + endpointPort: number; + endpointHost: string; + maxRetries: number; + retryInterval: number; + autoStart: boolean; +} + +interface SSHHost { + id: number; name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableConfigEditor: boolean; + defaultPath: string; + tunnelConnections: TunnelConnection[]; + createdAt: string; + updatedAt: string; +} + +interface TunnelConfig { + name: string; + hostName: string; sourceIP: string; sourceSSHPort: number; sourceUsername: string; @@ -85,6 +119,11 @@ interface HostConfig { isPinned: boolean; } +interface HostConfig { + host: SSHHost; + tunnels: TunnelConfig[]; +} + interface TunnelStatus { connected: boolean; status: ConnectionState; @@ -135,8 +174,6 @@ function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { status.reason = "Max retries exhausted"; } - // In Express, we'll use a different approach for broadcasting - // For now, we'll store the status and provide endpoints to fetch it connectionStatus.set(tunnelName, status); } @@ -244,7 +281,7 @@ function resetRetryState(tunnelName: string): void { }); } -function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, shouldRetry = true, isRemoteClosure = false): void { +function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, shouldRetry = true, isRemoteClosure = false): void { if (tunnelVerifications.has(tunnelName)) { try { const verification = tunnelVerifications.get(tunnelName); @@ -299,9 +336,9 @@ function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, sho return; } - if (shouldRetry && hostConfig) { - const maxRetries = hostConfig.maxRetries || 3; - const retryInterval = hostConfig.retryInterval || 5000; + if (shouldRetry && tunnelConfig) { + const maxRetries = tunnelConfig.maxRetries || 3; + const retryInterval = tunnelConfig.retryInterval || 5000; if (isRemoteClosure) { const currentCount = remoteClosureEvents.get(tunnelName) || 0; @@ -351,7 +388,7 @@ function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, sho if (!manualDisconnects.has(tunnelName)) { activeTunnels.delete(tunnelName); - connectSSHTunnel(hostConfig, retryCount); + connectSSHTunnel(tunnelConfig, retryCount); } }, retryInterval); @@ -368,7 +405,7 @@ function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, sho } // Tunnel verification function -function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPeriodic = false): void { +function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void { if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) { return; } @@ -411,7 +448,7 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe }); if (!isPeriodic) { - setupPingInterval(tunnelName, hostConfig); + setupPingInterval(tunnelName, tunnelConfig); } } else { logger.error(`Verification failed for '${tunnelName}': ${failureReason}`); @@ -425,12 +462,12 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe } activeTunnels.delete(tunnelName); - handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); + handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); } } function attemptVerification() { - const testCmd = `nc -z localhost ${hostConfig.sourcePort}`; + const testCmd = `nc -z localhost ${tunnelConfig.sourcePort}`; verificationConn.exec(testCmd, (err, stream) => { if (err) { @@ -447,7 +484,7 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe if (code === 0 && code !== undefined) { cleanupVerification(true); } else { - cleanupVerification(false, `Port ${hostConfig.sourcePort} is not accessible`); + cleanupVerification(false, `Port ${tunnelConfig.sourcePort} is not accessible`); } }); @@ -472,9 +509,9 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe }); const connOptions: any = { - host: hostConfig.sourceIP, - port: hostConfig.sourceSSHPort, - username: hostConfig.sourceUsername, + host: tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + username: tunnelConfig.sourceUsername, readyTimeout: 10000, algorithms: { kex: [ @@ -512,19 +549,19 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe } }; - if (hostConfig.sourceAuthMethod === "key" && hostConfig.sourceSSHKey) { - connOptions.privateKey = hostConfig.sourceSSHKey; - if (hostConfig.sourceKeyPassword) { - connOptions.passphrase = hostConfig.sourceKeyPassword; + if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { + connOptions.privateKey = tunnelConfig.sourceSSHKey; + if (tunnelConfig.sourceKeyPassword) { + connOptions.passphrase = tunnelConfig.sourceKeyPassword; } } else { - connOptions.password = hostConfig.sourcePassword; + connOptions.password = tunnelConfig.sourcePassword; } verificationConn.connect(connOptions); } -function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void { +function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void { const pingInterval = setInterval(() => { if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) { clearInterval(pingInterval); @@ -550,7 +587,7 @@ function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void { } activeTunnels.delete(tunnelName); - handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); + handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); return; } @@ -567,7 +604,7 @@ function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void { } activeTunnels.delete(tunnelName); - handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); + handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); } }); @@ -583,15 +620,15 @@ function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void { } activeTunnels.delete(tunnelName); - handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); + handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); }); }); }, 30000); // Ping every 30 seconds } // Main SSH tunnel connection function -function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { - const tunnelName = hostConfig.name; +function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { + const tunnelName = tunnelConfig.name; if (manualDisconnects.has(tunnelName)) { return; @@ -614,7 +651,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { isRemoteRetry: !!isRetryAfterRemoteClosure }); - if (!hostConfig || !hostConfig.sourceIP || !hostConfig.sourceUsername || !hostConfig.sourceSSHPort) { + if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) { logger.error(`Invalid connection details for '${tunnelName}'`); broadcastTunnelStatus(tunnelName, { connected: false, @@ -639,7 +676,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { activeTunnels.delete(tunnelName); if (!activeRetryTimers.has(tunnelName)) { - handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); + handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); } } }, 15000); @@ -679,7 +716,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { manualDisconnects.has(tunnelName) ); - handleDisconnect(tunnelName, hostConfig, !shouldNotRetry, isRemoteHostClosure); + handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry, isRemoteHostClosure); }); conn.on("close", () => { @@ -699,7 +736,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { } if (!activeRetryTimers.has(tunnelName)) { - handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); + handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); } } }); @@ -713,10 +750,10 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { } let tunnelCmd: string; - if (hostConfig.endpointAuthMethod === "key" && hostConfig.endpointSSHKey) { - tunnelCmd = `ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${hostConfig.endpointPort}:localhost:${hostConfig.sourcePort} ${hostConfig.endpointUsername}@${hostConfig.endpointIP}`; + if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) { + tunnelCmd = `ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`; } else { - tunnelCmd = `sshpass -p '${hostConfig.endpointPassword || ''}' ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${hostConfig.endpointPort}:localhost:${hostConfig.sourcePort} ${hostConfig.endpointUsername}@${hostConfig.endpointIP}`; + tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`; } conn.exec(tunnelCmd, (err, stream) => { @@ -732,7 +769,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { errorType === ERROR_TYPES.PORT || errorType === ERROR_TYPES.PERMISSION; - handleDisconnect(tunnelName, hostConfig, !shouldNotRetry); + handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry); return; } @@ -740,7 +777,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { setTimeout(() => { if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) { - verifyTunnelConnection(tunnelName, hostConfig, false); + verifyTunnelConnection(tunnelName, tunnelConfig, false); } }, 2000); @@ -783,11 +820,11 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { } if (!activeRetryTimers.has(tunnelName) && !retryExhaustedTunnels.has(tunnelName)) { - handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName), isLikelyRemoteClosure); + handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName), isLikelyRemoteClosure); } else if (retryExhaustedTunnels.has(tunnelName) && isLikelyRemoteClosure) { retryExhaustedTunnels.delete(tunnelName); retryCounters.delete(tunnelName); - handleDisconnect(tunnelName, hostConfig, true, true); + handleDisconnect(tunnelName, tunnelConfig, true, true); } }); @@ -835,16 +872,16 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { errorType === ERROR_TYPES.PERMISSION ); - handleDisconnect(tunnelName, hostConfig, !shouldNotRetry, isRemoteHostClosure); + handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry, isRemoteHostClosure); } }); }); }); const connOptions: any = { - host: hostConfig.sourceIP, - port: hostConfig.sourceSSHPort, - username: hostConfig.sourceUsername, + host: tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + username: tunnelConfig.sourceUsername, keepaliveInterval: 5000, keepaliveCountMax: 10, readyTimeout: 10000, @@ -885,13 +922,13 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { } }; - if (hostConfig.sourceAuthMethod === "key" && hostConfig.sourceSSHKey) { - connOptions.privateKey = hostConfig.sourceSSHKey; - if (hostConfig.sourceKeyPassword) { - connOptions.passphrase = hostConfig.sourceKeyPassword; + if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { + connOptions.privateKey = tunnelConfig.sourceSSHKey; + if (tunnelConfig.sourceKeyPassword) { + connOptions.passphrase = tunnelConfig.sourceKeyPassword; } } else { - connOptions.password = hostConfig.sourcePassword; + connOptions.password = tunnelConfig.sourcePassword; } conn.connect(connOptions); @@ -914,24 +951,24 @@ app.get('/status/:tunnelName', (req, res) => { }); app.post('/connect', (req, res) => { - const hostConfig: HostConfig = req.body; + const tunnelConfig: TunnelConfig = req.body; - if (!hostConfig || !hostConfig.name) { + if (!tunnelConfig || !tunnelConfig.name) { return res.status(400).json({ error: 'Invalid tunnel configuration' }); } - const tunnelName = hostConfig.name; + const tunnelName = tunnelConfig.name; // Reset retry state for new connection manualDisconnects.delete(tunnelName); retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); - // Store host config - hostConfigs.set(tunnelName, hostConfig); + // Store tunnel config + tunnelConfigs.set(tunnelName, tunnelConfig); // Start connection - connectSSHTunnel(hostConfig, 0); + connectSSHTunnel(tunnelConfig, 0); res.json({ message: 'Connection request received', tunnelName }); }); @@ -958,8 +995,8 @@ app.post('/disconnect', (req, res) => { manualDisconnect: true }); - const hostConfig = hostConfigs.get(tunnelName) || null; - handleDisconnect(tunnelName, hostConfig, false); + const tunnelConfig = tunnelConfigs.get(tunnelName) || null; + handleDisconnect(tunnelName, tunnelConfig, false); // Clear manual disconnect flag after a delay setTimeout(() => { @@ -972,55 +1009,80 @@ app.post('/disconnect', (req, res) => { // Auto-start functionality async function initializeAutoStartTunnels(): Promise { try { - // Fetch auto-start tunnels from database - const response = await axios.get('http://localhost:8081/ssh_tunnel/tunnel?allAutoStart=1', { + // Fetch hosts with auto-start tunnel connections + const response = await axios.get('http://localhost:8081/ssh/host', { headers: { 'Content-Type': 'application/json', 'X-Internal-Request': '1' } }); - const tunnels = response.data || []; - const autoStartTunnels = tunnels.filter((tunnel: any) => tunnel.autoStart); + const hosts: SSHHost[] = response.data || []; + const autoStartTunnels: TunnelConfig[] = []; + + // Process each host and extract auto-start tunnel connections + for (const host of hosts) { + if (host.enableTunnel && host.tunnelConnections) { + for (const tunnelConnection of host.tunnelConnections) { + if (tunnelConnection.autoStart) { + // Find the endpoint host + const endpointHost = hosts.find(h => + h.name === tunnelConnection.endpointHost || + `${h.username}@${h.ip}` === tunnelConnection.endpointHost + ); + + if (endpointHost) { + const tunnelConfig: TunnelConfig = { + name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`, + hostName: host.name || `${host.username}@${host.ip}`, + sourceIP: host.ip, + sourceSSHPort: host.port, + sourceUsername: host.username, + sourcePassword: host.password, + sourceAuthMethod: host.authType, + sourceSSHKey: host.key, + sourceKeyPassword: host.keyPassword, + sourceKeyType: host.keyType, + endpointIP: endpointHost.ip, + endpointSSHPort: endpointHost.port, + endpointUsername: endpointHost.username, + endpointPassword: endpointHost.password, + 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, + isPinned: host.pin + }; + + autoStartTunnels.push(tunnelConfig); + } + } + } + } + } logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`); - for (const tunnel of autoStartTunnels) { - const hostConfig: HostConfig = { - name: tunnel.name, - sourceIP: tunnel.sourceIP, - sourceSSHPort: tunnel.sourceSSHPort, - sourceUsername: tunnel.sourceUsername, - sourcePassword: tunnel.sourcePassword, - sourceAuthMethod: tunnel.sourceAuthMethod, - sourceSSHKey: tunnel.sourceSSHKey, - sourceKeyPassword: tunnel.sourceKeyPassword, - sourceKeyType: tunnel.sourceKeyType, - endpointIP: tunnel.endpointIP, - endpointSSHPort: tunnel.endpointSSHPort, - endpointUsername: tunnel.endpointUsername, - endpointPassword: tunnel.endpointPassword, - endpointAuthMethod: tunnel.endpointAuthMethod, - endpointSSHKey: tunnel.endpointSSHKey, - endpointKeyPassword: tunnel.endpointKeyPassword, - endpointKeyType: tunnel.endpointKeyType, - sourcePort: tunnel.sourcePort, - endpointPort: tunnel.endpointPort, - maxRetries: tunnel.maxRetries || 3, - retryInterval: tunnel.retryInterval || 5000, - autoStart: tunnel.autoStart, - isPinned: tunnel.isPinned || false - }; - - hostConfigs.set(tunnel.name, hostConfig); + // Start each auto-start tunnel + for (const tunnelConfig of autoStartTunnels) { + tunnelConfigs.set(tunnelConfig.name, tunnelConfig); - // Start the tunnel + // Start the tunnel with a delay to avoid overwhelming the system setTimeout(() => { - connectSSHTunnel(hostConfig, 0); - }, 1000); // Stagger startup to avoid overwhelming the system + connectSSHTunnel(tunnelConfig, 0); + }, 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); } - } catch (error) { - logger.error('Failed to initialize auto-start tunnels:', error); } } @@ -1035,29 +1097,29 @@ app.get('/health', (req, res) => { // Get all tunnel configurations app.get('/tunnels', (req, res) => { - const tunnels = Array.from(hostConfigs.values()); + const tunnels = Array.from(tunnelConfigs.values()); res.json(tunnels); }); // Update tunnel configuration app.put('/tunnel/:name', (req, res) => { const { name } = req.params; - const hostConfig: HostConfig = req.body; + const tunnelConfig: TunnelConfig = req.body; - if (!hostConfig || !hostConfig.name) { + if (!tunnelConfig || !tunnelConfig.name) { return res.status(400).json({ error: 'Invalid tunnel configuration' }); } - hostConfigs.set(name, hostConfig); + tunnelConfigs.set(name, tunnelConfig); // If tunnel is currently connected, disconnect and reconnect with new config if (activeTunnels.has(name)) { manualDisconnects.add(name); - handleDisconnect(name, hostConfig, false); + handleDisconnect(name, tunnelConfig, false); setTimeout(() => { manualDisconnects.delete(name); - connectSSHTunnel(hostConfig, 0); + connectSSHTunnel(tunnelConfig, 0); }, 2000); } @@ -1071,12 +1133,12 @@ app.delete('/tunnel/:name', (req, res) => { // Disconnect if active if (activeTunnels.has(name)) { manualDisconnects.add(name); - const hostConfig = hostConfigs.get(name) || null; - handleDisconnect(name, hostConfig, false); + const tunnelConfig = tunnelConfigs.get(name) || null; + handleDisconnect(name, tunnelConfig, false); } // Remove from configurations - hostConfigs.delete(name); + tunnelConfigs.delete(name); res.json({ message: 'Tunnel deleted', name }); }); @@ -1084,7 +1146,6 @@ app.delete('/tunnel/:name', (req, res) => { // Start the server const PORT = 8083; app.listen(PORT, () => { - // Initialize auto-start tunnels after a short delay setTimeout(() => { initializeAutoStartTunnels(); }, 2000);