Update ssh tunnel to new system

This commit is contained in:
LukeGus
2025-07-26 23:01:45 -05:00
parent abeba66432
commit 5e88f8496e
7 changed files with 638 additions and 2127 deletions

View File

@@ -471,7 +471,9 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
filteredSshByFolder[folder] = hosts.filter(conn => { filteredSshByFolder[folder] = hosts.filter(conn => {
const q = debouncedSearch.trim().toLowerCase(); const q = debouncedSearch.trim().toLowerCase();
if (!q) return true; 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(
<Input <Input
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => 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" className="w-full h-8 text-sm bg-background border border-border rounded"
autoComplete="off" autoComplete="off"
/> />

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { import {
CornerDownLeft, CornerDownLeft,
Hammer Hammer, Pin
} from "lucide-react" } from "lucide-react"
import { import {
@@ -131,10 +131,16 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
if (!debouncedSearch.trim()) return hosts; if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase(); const q = debouncedSearch.trim().toLowerCase();
return hosts.filter(h => { return hosts.filter(h => {
const name = (h.name || "").toLowerCase(); const searchableText = [
const ip = (h.ip || "").toLowerCase(); h.name || '',
const tags = Array.isArray(h.tags) ? h.tags : []; h.username,
return name.includes(q) || ip.includes(q) || tags.some((tag: string) => tag.toLowerCase().includes(q)); h.ip,
h.folder || '',
...(h.tags || []),
h.authType,
h.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(q);
}); });
}, [hosts, debouncedSearch]); }, [hosts, debouncedSearch]);
@@ -214,7 +220,7 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
<Input <Input
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => 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" className="w-full h-8 text-sm bg-background border border-border rounded"
autoComplete="off" autoComplete="off"
/> />
@@ -343,7 +349,9 @@ const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect }: {
onClick={() => onHostConnect(host)} onClick={() => onHostConnect(host)}
> >
<div className="flex items-center w-full"> <div className="flex items-center w-full">
{host.pin && <span className="text-yellow-400 mr-1 flex-shrink-0"></span>} {host.pin &&
<Pin className="h-3.5 mr-1 w-3.5 mt-0.5 text-yellow-500 flex-shrink-0" />
}
<span className="font-medium truncate">{host.name || host.ip}</span> <span className="font-medium truncate">{host.name || host.ip}</span>
</div> </div>
</div> </div>

View File

@@ -1,90 +1,75 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx"; import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx"; import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import { getSSHHosts } from "@/apps/SSH/ssh-axios";
import axios from "axios"; import axios from "axios";
interface ConfigEditorProps { interface ConfigEditorProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
} }
interface SSHTunnel { interface TunnelConnection {
id: number;
name: string;
folder: string;
sourcePort: number; sourcePort: number;
endpointPort: number; endpointPort: number;
sourceIP: string; endpointHost: 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; maxRetries: number;
retryInterval: number; retryInterval: number;
connectionState: string;
autoStart: boolean; 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 { export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
const [tunnels, setTunnels] = useState<SSHTunnel[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false); const [hostStatuses, setHostStatuses] = useState<Record<number, HostStatus>>({});
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
const [tunnelStatusMap, setTunnelStatusMap] = useState<Record<string, any>>({});
const sidebarRef = React.useRef<any>(null);
const fetchTunnels = useCallback(async () => { const fetchHosts = useCallback(async () => {
setTunnelsLoading(true);
setTunnelsError(null);
try { try {
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; const hostsData = await getSSHHosts();
const res = await axios.get( setHosts(hostsData);
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + '/ssh_tunnel/tunnel', } catch (err) {
{ headers: { Authorization: `Bearer ${jwt}` } } // Silent error handling
);
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);
} }
}, []); }, []);
@@ -92,17 +77,75 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
const fetchTunnelStatuses = useCallback(async () => { const fetchTunnelStatuses = useCallback(async () => {
try { try {
const res = await axios.get('http://localhost:8083/status'); 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<number, HostStatus> = {};
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) { } catch (err) {
// Optionally handle error // Silent error handling
} }
}, []); }, [hosts]);
useEffect(() => { useEffect(() => {
fetchTunnels(); fetchHosts();
const interval = setInterval(fetchTunnels, 10000); const interval = setInterval(fetchHosts, 10000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchTunnels]); }, [fetchHosts]);
useEffect(() => { useEffect(() => {
fetchTunnelStatuses(); fetchTunnelStatuses();
@@ -110,94 +153,88 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchTunnelStatuses]); }, [fetchTunnelStatuses]);
// Merge backend status into tunnels const handleConnect = async (hostId: number) => {
const tunnelsWithStatus = tunnels.map(tunnel => { const host = hosts.find(h => h.id === hostId);
const status = tunnelStatusMap[tunnel.name] || {}; if (!host || !host.tunnelConnections || host.tunnelConnections.length === 0) {
return { 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 (tunnelId: string) => {
// Immediately set to CONNECTING for instant UI feedback // Immediately set to CONNECTING for instant UI feedback
setTunnels(prev => prev.map(t => setHostStatuses(prev => ({
t.id.toString() === tunnelId ...prev,
? { ...t, connectionState: "CONNECTING" } [hostId]: { ...prev[hostId], connectionState: "connecting" }
: t }));
));
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
if (!tunnel) return;
try { try {
await axios.post('http://localhost:8083/connect', { // For each tunnel connection, create a tunnel configuration
...tunnel, for (const tunnelConnection of host.tunnelConnections) {
name: tunnel.name // Find the endpoint host configuration
}); const endpointHost = hosts.find(h =>
// No need to update state here; polling will update real status 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) { } 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 // Immediately set to DISCONNECTING for instant UI feedback
setTunnels(prev => prev.map(t => setHostStatuses(prev => ({
t.id.toString() === tunnelId ...prev,
? { ...t, connectionState: "DISCONNECTING" } [hostId]: { ...prev[hostId], connectionState: "disconnecting" }
: t }));
));
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
if (!tunnel) return;
try { try {
await axios.post('http://localhost:8083/disconnect', { // Disconnect all tunnels for this host
tunnelName: tunnel.name for (const tunnelConnection of host.tunnelConnections) {
}); const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`;
// No need to update state here; polling will update real status await axios.post('http://localhost:8083/disconnect', { tunnelName });
}
} catch (err) { } catch (err) {
// Optionally handle error // Silent error handling
}
};
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);
} }
}; };
@@ -205,19 +242,15 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
<div className="flex h-screen w-full"> <div className="flex h-screen w-full">
<div className="w-64 flex-shrink-0"> <div className="w-64 flex-shrink-0">
<SSHTunnelSidebar <SSHTunnelSidebar
ref={sidebarRef}
onSelectView={onSelectView} onSelectView={onSelectView}
onTunnelAdded={fetchTunnels}
onEditTunnel={handleEditTunnelClick}
/> />
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<SSHTunnelViewer <SSHTunnelViewer
tunnels={tunnelsWithStatus} hosts={hosts}
hostStatuses={hostStatuses}
onConnect={handleConnect} onConnect={handleConnect}
onDisconnect={handleDisconnect} onDisconnect={handleDisconnect}
onDeleteTunnel={handleDeleteTunnel}
onEditTunnel={handleEditTunnelClick}
/> />
</div> </div>
</div> </div>

View File

@@ -2,7 +2,8 @@ import React from "react";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.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 = { const CONNECTION_STATES = {
DISCONNECTED: "disconnected", DISCONNECTED: "disconnected",
@@ -15,27 +16,62 @@ const CONNECTION_STATES = {
DISCONNECTING: "disconnecting" 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 { interface SSHTunnelObjectProps {
hostConfig: any; host: SSHHost;
onConnect?: () => void; onConnect?: (hostId: number) => void;
onDisconnect?: () => void; onDisconnect?: (hostId: number) => void;
onDelete?: () => void; connectionState?: keyof typeof CONNECTION_STATES | string;
onEdit?: () => void; statusReason?: string;
connectionState?: keyof typeof CONNECTION_STATES; statusErrorType?: string;
isPinned?: boolean; statusRetryCount?: number;
statusMaxRetries?: number;
statusNextRetryIn?: number;
statusRetryExhausted?: boolean;
} }
export function SSHTunnelObject({ export function SSHTunnelObject({
hostConfig = {}, host,
onConnect, onConnect,
onDisconnect, onDisconnect,
onDelete,
onEdit,
connectionState = "DISCONNECTED", connectionState = "DISCONNECTED",
isPinned = false statusReason,
statusErrorType,
statusRetryCount,
statusMaxRetries,
statusNextRetryIn,
statusRetryExhausted
}: SSHTunnelObjectProps): React.ReactElement { }: SSHTunnelObjectProps): React.ReactElement {
const getStatusColor = (state: keyof typeof CONNECTION_STATES) => { const getStatusColor = (state: string) => {
switch (state) { const upperState = state.toUpperCase();
switch (upperState) {
case "CONNECTED": case "CONNECTED":
return "bg-green-500"; return "bg-green-500";
case "CONNECTING": case "CONNECTING":
@@ -51,8 +87,9 @@ export function SSHTunnelObject({
} }
}; };
const getStatusText = (state: keyof typeof CONNECTION_STATES) => { const getStatusText = (state: string) => {
switch (state) { const upperState = state.toUpperCase();
switch (upperState) {
case "CONNECTED": case "CONNECTED":
return "Connected"; return "Connected";
case "CONNECTING": case "CONNECTING":
@@ -70,43 +107,26 @@ export function SSHTunnelObject({
} }
}; };
const isConnected = connectionState === "CONNECTED" || connectionState === "connected";
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING", "connecting", "verifying", "retrying"].includes(connectionState);
const isConnected = connectionState === "CONNECTED"; const isDisconnecting = connectionState === "DISCONNECTING" || connectionState === "disconnecting";
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING"].includes(connectionState);
const isDisconnecting = connectionState === "DISCONNECTING";
return ( return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0"> <Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
{/* Hover overlay buttons */} <div className="p-3">
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10 flex gap-1"> {/* Host Header */}
<Button <div className="flex items-center justify-between gap-2 mb-2">
size="sm" <div className="flex items-center gap-2 flex-1 min-w-0">
variant="secondary" {host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />}
className="h-8 w-8 p-0 bg-black/50 hover:bg-black/70 border-0" <div className="flex-1 min-w-0">
onClick={onEdit} <h3 className="font-semibold text-card-foreground truncate">
> {host.name || `${host.username}@${host.ip}`}
<Edit className="w-4 h-4" /> </h3>
</Button> <p className="text-xs text-muted-foreground truncate">
<Button {host.ip}:{host.port} {host.username}
size="sm" </p>
variant="destructive" </div>
className="h-8 w-8 p-0 bg-red-500/50 hover:bg-red-500/70 border-0"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="p-2">
<div className="flex items-center justify-between gap-1 mb-1">
<div className="text-lg font-semibold text-card-foreground flex-1 min-w-0">
<span className="break-words">
{isPinned && <span className="text-yellow-400 mr-1 flex-shrink-0"></span>}
{hostConfig.name || "My SSH Tunnel"}
</span>
</div> </div>
<div className="w-px h-4 bg-border mx-1"></div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${getStatusColor(connectionState)}`} /> <div className={`w-2 h-2 rounded-full ${getStatusColor(connectionState)}`} />
<span className="text-sm text-muted-foreground whitespace-nowrap"> <span className="text-sm text-muted-foreground whitespace-nowrap">
@@ -115,28 +135,53 @@ export function SSHTunnelObject({
</div> </div>
</div> </div>
<Separator className="mb-1" /> {/* Tags */}
<div className="space-y-1 mb-2"> {host.tags && host.tags.length > 0 && (
<div className="flex items-center justify-between text-sm"> <div className="flex flex-wrap gap-1 mb-2">
<span className="text-muted-foreground flex-shrink-0 mr-2">Source:</span> {host.tags.slice(0, 3).map((tag, index) => (
<span className="text-card-foreground font-mono text-right break-all"> <Badge key={index} variant="secondary" className="text-xs px-1 py-0">
{hostConfig.source || "localhost:22"} <Tag className="h-2 w-2 mr-0.5" />
</span> {tag}
</div> </Badge>
<div className="flex items-center justify-between text-sm"> ))}
<span className="text-muted-foreground flex-shrink-0 mr-2">Endpoint:</span> {host.tags.length > 3 && (
<span className="text-card-foreground font-mono text-right break-all"> <Badge variant="outline" className="text-xs px-1 py-0">
{hostConfig.endpoint || "test:224"} +{host.tags.length - 3}
</span> </Badge>
)}
</div> </div>
)}
<Separator className="mb-2" />
{/* Tunnel Connections */}
<div className="space-y-2 mb-3">
<h4 className="text-sm font-medium text-card-foreground">Tunnel Connections</h4>
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-1">
{host.tunnelConnections.map((tunnel, index) => (
<div key={index} className="text-xs bg-muted/50 rounded px-2 py-1">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Port {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort}</span>
{tunnel.autoStart && (
<Badge variant="outline" className="text-xs px-1 py-0">
Auto
</Badge>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No tunnel connections configured</p>
)}
</div> </div>
<Separator className="my-1" />
{/* Error/Status Reason */} {/* Error/Status Reason */}
{((connectionState === "FAILED" || connectionState === "UNSTABLE") && hostConfig.statusReason) && ( {((connectionState === "FAILED" || connectionState === "UNSTABLE") && statusReason) && (
<div className="mb-2 text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20"> <div className="mb-2 text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
{hostConfig.statusReason} {statusReason}
{typeof hostConfig.statusReason === 'string' && hostConfig.statusReason.includes('Max retries exhausted') && ( {statusReason && statusReason.includes('Max retries exhausted') && (
<> <>
<br /> <br />
<span> <span>
@@ -146,9 +191,21 @@ export function SSHTunnelObject({
)} )}
</div> </div>
)} )}
<div className="flex gap-2 mt-2">
{/* Retry Info */}
{connectionState === "RETRYING" && statusRetryCount && statusMaxRetries && (
<div className="mb-2 text-xs text-yellow-600 bg-yellow-500/10 rounded px-2 py-1 border border-yellow-500/20">
Retry {statusRetryCount}/{statusMaxRetries}
{statusNextRetryIn && (
<span> Next retry in {statusNextRetryIn}s</span>
)}
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
<Button <Button
onClick={onConnect} onClick={() => onConnect?.(host.id)}
disabled={isConnected || isConnecting || isDisconnecting} disabled={isConnected || isConnecting || isDisconnecting}
className="flex-1" className="flex-1"
variant={isConnected ? "secondary" : "default"} variant={isConnected ? "secondary" : "default"}
@@ -165,7 +222,7 @@ export function SSHTunnelObject({
)} )}
</Button> </Button>
<Button <Button
onClick={onDisconnect} onClick={() => onDisconnect?.(host.id)}
disabled={!isConnected || isDisconnecting || isConnecting} disabled={!isConnected || isDisconnecting || isConnecting}
variant="outline" variant="outline"
className="flex-1" className="flex-1"

File diff suppressed because it is too large Load Diff

View File

@@ -2,81 +2,120 @@ import React from "react";
import { SSHTunnelObject } from "./SSHTunnelObject.tsx"; import { SSHTunnelObject } from "./SSHTunnelObject.tsx";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx";
import { Separator } from "@/components/ui/separator.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 { interface SSHTunnelViewerProps {
tunnels: Array<{ hosts: SSHHost[];
id: number; hostStatuses?: Record<number, HostStatus>;
name: string; onConnect?: (hostId: number) => void;
folder: string; onDisconnect?: (hostId: number) => void;
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;
} }
export function SSHTunnelViewer({ export function SSHTunnelViewer({
tunnels = [], hosts = [],
onConnect, hostStatuses = {},
onDisconnect, onConnect,
onDeleteTunnel, onDisconnect
onEditTunnel
}: SSHTunnelViewerProps): React.ReactElement { }: SSHTunnelViewerProps): React.ReactElement {
const handleConnect = (tunnelId: string) => { const [searchQuery, setSearchQuery] = React.useState("");
onConnect?.(tunnelId); 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) => { const handleDisconnect = (hostId: number) => {
onDisconnect?.(tunnelId); onDisconnect?.(hostId);
}; };
// Group tunnels by folder and sort // Filter hosts by search query
const tunnelsByFolder = React.useMemo(() => { const filteredHosts = React.useMemo(() => {
const map: Record<string, typeof tunnels> = {}; if (!debouncedSearch.trim()) return hosts;
tunnels.forEach(tunnel => {
const folder = tunnel.folder && tunnel.folder.trim() ? tunnel.folder : 'No Folder'; 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<string, SSHHost[]> = {};
tunnelHosts.forEach(host => {
const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
if (!map[folder]) map[folder] = []; if (!map[folder]) map[folder] = [];
map[folder].push(tunnel); map[folder].push(host);
}); });
return map; return map;
}, [tunnels]); }, [tunnelHosts]);
const sortedFolders = React.useMemo(() => { const sortedFolders = React.useMemo(() => {
const folders = Object.keys(tunnelsByFolder); const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => { folders.sort((a, b) => {
if (a === 'No Folder') return -1; if (a === 'Uncategorized') return -1;
if (b === 'No Folder') return 1; if (b === 'Uncategorized') return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
return folders; return folders;
}, [tunnelsByFolder]); }, [hostsByFolder]);
const getSortedTunnels = (arr: typeof tunnels) => { const getSortedHosts = (arr: SSHHost[]) => {
const pinned = arr.filter(t => t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || '')); const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(t => !t.isPinned).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]; return [...pinned, ...rest];
}; };
@@ -93,14 +132,28 @@ export function SSHTunnelViewer({
</p> </p>
</div> </div>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Accordion Layout */} {/* Accordion Layout */}
{tunnels.length === 0 ? ( {tunnelHosts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<h3 className="text-lg font-semibold text-foreground mb-2"> <h3 className="text-lg font-semibold text-foreground mb-2">
No SSH Tunnels No SSH Tunnels
</h3> </h3>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
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."
}
</p> </p>
</div> </div>
) : ( ) : (
@@ -112,16 +165,19 @@ export function SSHTunnelViewer({
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1"> <AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
<div className="grid grid-cols-4 gap-6 w-full"> <div className="grid grid-cols-4 gap-6 w-full">
{getSortedTunnels(tunnelsByFolder[folder]).map((tunnel, tunnelIndex) => ( {getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
<div key={tunnel.id} className="w-full"> <div key={host.id} className="w-full">
<SSHTunnelObject <SSHTunnelObject
hostConfig={tunnel} host={host}
connectionState={tunnel.connectionState as any} connectionState={hostStatuses[host.id]?.connectionState as any}
isPinned={tunnel.isPinned} statusReason={hostStatuses[host.id]?.statusReason}
onConnect={() => handleConnect(tunnel.id.toString())} statusErrorType={hostStatuses[host.id]?.statusErrorType}
onDisconnect={() => handleDisconnect(tunnel.id.toString())} statusRetryCount={hostStatuses[host.id]?.statusRetryCount}
onDelete={() => onDeleteTunnel?.(tunnel.id.toString())} statusMaxRetries={hostStatuses[host.id]?.statusMaxRetries}
onEdit={() => onEditTunnel?.(tunnel.id.toString())} statusNextRetryIn={hostStatuses[host.id]?.statusNextRetryIn}
statusRetryExhausted={hostStatuses[host.id]?.statusRetryExhausted}
onConnect={() => handleConnect(host.id)}
onDisconnect={() => handleDisconnect(host.id)}
/> />
</div> </div>
))} ))}

View File

@@ -46,21 +46,55 @@ const logger = {
} }
}; };
// State management // State management for host-based tunnels
const activeTunnels = new Map<string, Client>(); const activeTunnels = new Map<string, Client>(); // tunnelName -> Client
const retryCounters = new Map<string, number>(); const retryCounters = new Map<string, number>(); // tunnelName -> retryCount
const connectionStatus = new Map<string, TunnelStatus>(); const connectionStatus = new Map<string, TunnelStatus>(); // tunnelName -> status
const tunnelVerifications = new Map<string, VerificationData>(); const tunnelVerifications = new Map<string, VerificationData>(); // tunnelName -> verification
const manualDisconnects = new Set<string>(); const manualDisconnects = new Set<string>(); // tunnelNames
const verificationTimers = new Map<string, NodeJS.Timeout>(); const verificationTimers = new Map<string, NodeJS.Timeout>(); // timer keys -> timeout
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer
const retryExhaustedTunnels = new Set<string>(); const retryExhaustedTunnels = new Set<string>(); // tunnelNames
const remoteClosureEvents = new Map<string, number>(); const remoteClosureEvents = new Map<string, number>(); // tunnelName -> count
const hostConfigs = new Map<string, HostConfig>(); const hostConfigs = new Map<string, HostConfig>(); // hostName -> hostConfig
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
// Types // Types
interface HostConfig { interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string; 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; sourceIP: string;
sourceSSHPort: number; sourceSSHPort: number;
sourceUsername: string; sourceUsername: string;
@@ -85,6 +119,11 @@ interface HostConfig {
isPinned: boolean; isPinned: boolean;
} }
interface HostConfig {
host: SSHHost;
tunnels: TunnelConfig[];
}
interface TunnelStatus { interface TunnelStatus {
connected: boolean; connected: boolean;
status: ConnectionState; status: ConnectionState;
@@ -135,8 +174,6 @@ function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
status.reason = "Max retries exhausted"; 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); 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)) { if (tunnelVerifications.has(tunnelName)) {
try { try {
const verification = tunnelVerifications.get(tunnelName); const verification = tunnelVerifications.get(tunnelName);
@@ -299,9 +336,9 @@ function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, sho
return; return;
} }
if (shouldRetry && hostConfig) { if (shouldRetry && tunnelConfig) {
const maxRetries = hostConfig.maxRetries || 3; const maxRetries = tunnelConfig.maxRetries || 3;
const retryInterval = hostConfig.retryInterval || 5000; const retryInterval = tunnelConfig.retryInterval || 5000;
if (isRemoteClosure) { if (isRemoteClosure) {
const currentCount = remoteClosureEvents.get(tunnelName) || 0; const currentCount = remoteClosureEvents.get(tunnelName) || 0;
@@ -351,7 +388,7 @@ function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, sho
if (!manualDisconnects.has(tunnelName)) { if (!manualDisconnects.has(tunnelName)) {
activeTunnels.delete(tunnelName); activeTunnels.delete(tunnelName);
connectSSHTunnel(hostConfig, retryCount); connectSSHTunnel(tunnelConfig, retryCount);
} }
}, retryInterval); }, retryInterval);
@@ -368,7 +405,7 @@ function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, sho
} }
// Tunnel verification function // 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)) { if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
return; return;
} }
@@ -411,7 +448,7 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe
}); });
if (!isPeriodic) { if (!isPeriodic) {
setupPingInterval(tunnelName, hostConfig); setupPingInterval(tunnelName, tunnelConfig);
} }
} else { } else {
logger.error(`Verification failed for '${tunnelName}': ${failureReason}`); logger.error(`Verification failed for '${tunnelName}': ${failureReason}`);
@@ -425,12 +462,12 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe
} }
activeTunnels.delete(tunnelName); activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
} }
} }
function attemptVerification() { function attemptVerification() {
const testCmd = `nc -z localhost ${hostConfig.sourcePort}`; const testCmd = `nc -z localhost ${tunnelConfig.sourcePort}`;
verificationConn.exec(testCmd, (err, stream) => { verificationConn.exec(testCmd, (err, stream) => {
if (err) { if (err) {
@@ -447,7 +484,7 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe
if (code === 0 && code !== undefined) { if (code === 0 && code !== undefined) {
cleanupVerification(true); cleanupVerification(true);
} else { } 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 = { const connOptions: any = {
host: hostConfig.sourceIP, host: tunnelConfig.sourceIP,
port: hostConfig.sourceSSHPort, port: tunnelConfig.sourceSSHPort,
username: hostConfig.sourceUsername, username: tunnelConfig.sourceUsername,
readyTimeout: 10000, readyTimeout: 10000,
algorithms: { algorithms: {
kex: [ kex: [
@@ -512,19 +549,19 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe
} }
}; };
if (hostConfig.sourceAuthMethod === "key" && hostConfig.sourceSSHKey) { if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
connOptions.privateKey = hostConfig.sourceSSHKey; connOptions.privateKey = tunnelConfig.sourceSSHKey;
if (hostConfig.sourceKeyPassword) { if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = hostConfig.sourceKeyPassword; connOptions.passphrase = tunnelConfig.sourceKeyPassword;
} }
} else { } else {
connOptions.password = hostConfig.sourcePassword; connOptions.password = tunnelConfig.sourcePassword;
} }
verificationConn.connect(connOptions); verificationConn.connect(connOptions);
} }
function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void { function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) { if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
clearInterval(pingInterval); clearInterval(pingInterval);
@@ -550,7 +587,7 @@ function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void {
} }
activeTunnels.delete(tunnelName); activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
return; return;
} }
@@ -567,7 +604,7 @@ function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void {
} }
activeTunnels.delete(tunnelName); 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); activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
}); });
}); });
}, 30000); // Ping every 30 seconds }, 30000); // Ping every 30 seconds
} }
// Main SSH tunnel connection function // Main SSH tunnel connection function
function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void { function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
const tunnelName = hostConfig.name; const tunnelName = tunnelConfig.name;
if (manualDisconnects.has(tunnelName)) { if (manualDisconnects.has(tunnelName)) {
return; return;
@@ -614,7 +651,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
isRemoteRetry: !!isRetryAfterRemoteClosure 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}'`); logger.error(`Invalid connection details for '${tunnelName}'`);
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
@@ -639,7 +676,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
activeTunnels.delete(tunnelName); activeTunnels.delete(tunnelName);
if (!activeRetryTimers.has(tunnelName)) { if (!activeRetryTimers.has(tunnelName)) {
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName)); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
} }
} }
}, 15000); }, 15000);
@@ -679,7 +716,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
manualDisconnects.has(tunnelName) manualDisconnects.has(tunnelName)
); );
handleDisconnect(tunnelName, hostConfig, !shouldNotRetry, isRemoteHostClosure); handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry, isRemoteHostClosure);
}); });
conn.on("close", () => { conn.on("close", () => {
@@ -699,7 +736,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
} }
if (!activeRetryTimers.has(tunnelName)) { 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; let tunnelCmd: string;
if (hostConfig.endpointAuthMethod === "key" && hostConfig.endpointSSHKey) { if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
tunnelCmd = `ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${hostConfig.endpointPort}:localhost:${hostConfig.sourcePort} ${hostConfig.endpointUsername}@${hostConfig.endpointIP}`; tunnelCmd = `ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
} else { } 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) => { conn.exec(tunnelCmd, (err, stream) => {
@@ -732,7 +769,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
errorType === ERROR_TYPES.PORT || errorType === ERROR_TYPES.PORT ||
errorType === ERROR_TYPES.PERMISSION; errorType === ERROR_TYPES.PERMISSION;
handleDisconnect(tunnelName, hostConfig, !shouldNotRetry); handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
return; return;
} }
@@ -740,7 +777,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
setTimeout(() => { setTimeout(() => {
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) { if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
verifyTunnelConnection(tunnelName, hostConfig, false); verifyTunnelConnection(tunnelName, tunnelConfig, false);
} }
}, 2000); }, 2000);
@@ -783,11 +820,11 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
} }
if (!activeRetryTimers.has(tunnelName) && !retryExhaustedTunnels.has(tunnelName)) { 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) { } else if (retryExhaustedTunnels.has(tunnelName) && isLikelyRemoteClosure) {
retryExhaustedTunnels.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName);
retryCounters.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 errorType === ERROR_TYPES.PERMISSION
); );
handleDisconnect(tunnelName, hostConfig, !shouldNotRetry, isRemoteHostClosure); handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry, isRemoteHostClosure);
} }
}); });
}); });
}); });
const connOptions: any = { const connOptions: any = {
host: hostConfig.sourceIP, host: tunnelConfig.sourceIP,
port: hostConfig.sourceSSHPort, port: tunnelConfig.sourceSSHPort,
username: hostConfig.sourceUsername, username: tunnelConfig.sourceUsername,
keepaliveInterval: 5000, keepaliveInterval: 5000,
keepaliveCountMax: 10, keepaliveCountMax: 10,
readyTimeout: 10000, readyTimeout: 10000,
@@ -885,13 +922,13 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
} }
}; };
if (hostConfig.sourceAuthMethod === "key" && hostConfig.sourceSSHKey) { if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
connOptions.privateKey = hostConfig.sourceSSHKey; connOptions.privateKey = tunnelConfig.sourceSSHKey;
if (hostConfig.sourceKeyPassword) { if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = hostConfig.sourceKeyPassword; connOptions.passphrase = tunnelConfig.sourceKeyPassword;
} }
} else { } else {
connOptions.password = hostConfig.sourcePassword; connOptions.password = tunnelConfig.sourcePassword;
} }
conn.connect(connOptions); conn.connect(connOptions);
@@ -914,24 +951,24 @@ app.get('/status/:tunnelName', (req, res) => {
}); });
app.post('/connect', (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' }); return res.status(400).json({ error: 'Invalid tunnel configuration' });
} }
const tunnelName = hostConfig.name; const tunnelName = tunnelConfig.name;
// Reset retry state for new connection // Reset retry state for new connection
manualDisconnects.delete(tunnelName); manualDisconnects.delete(tunnelName);
retryCounters.delete(tunnelName); retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName);
// Store host config // Store tunnel config
hostConfigs.set(tunnelName, hostConfig); tunnelConfigs.set(tunnelName, tunnelConfig);
// Start connection // Start connection
connectSSHTunnel(hostConfig, 0); connectSSHTunnel(tunnelConfig, 0);
res.json({ message: 'Connection request received', tunnelName }); res.json({ message: 'Connection request received', tunnelName });
}); });
@@ -958,8 +995,8 @@ app.post('/disconnect', (req, res) => {
manualDisconnect: true manualDisconnect: true
}); });
const hostConfig = hostConfigs.get(tunnelName) || null; const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, hostConfig, false); handleDisconnect(tunnelName, tunnelConfig, false);
// Clear manual disconnect flag after a delay // Clear manual disconnect flag after a delay
setTimeout(() => { setTimeout(() => {
@@ -972,55 +1009,80 @@ app.post('/disconnect', (req, res) => {
// Auto-start functionality // Auto-start functionality
async function initializeAutoStartTunnels(): Promise<void> { async function initializeAutoStartTunnels(): Promise<void> {
try { try {
// Fetch auto-start tunnels from database // Fetch hosts with auto-start tunnel connections
const response = await axios.get('http://localhost:8081/ssh_tunnel/tunnel?allAutoStart=1', { const response = await axios.get('http://localhost:8081/ssh/host', {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Internal-Request': '1' 'X-Internal-Request': '1'
} }
}); });
const tunnels = response.data || []; const hosts: SSHHost[] = response.data || [];
const autoStartTunnels = tunnels.filter((tunnel: any) => tunnel.autoStart); 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`); logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
for (const tunnel of autoStartTunnels) { // Start each auto-start tunnel
const hostConfig: HostConfig = { for (const tunnelConfig of autoStartTunnels) {
name: tunnel.name, tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
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 the tunnel // Start the tunnel with a delay to avoid overwhelming the system
setTimeout(() => { setTimeout(() => {
connectSSHTunnel(hostConfig, 0); connectSSHTunnel(tunnelConfig, 0);
}, 1000); // Stagger startup to avoid overwhelming the system }, 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 // Get all tunnel configurations
app.get('/tunnels', (req, res) => { app.get('/tunnels', (req, res) => {
const tunnels = Array.from(hostConfigs.values()); const tunnels = Array.from(tunnelConfigs.values());
res.json(tunnels); res.json(tunnels);
}); });
// Update tunnel configuration // Update tunnel configuration
app.put('/tunnel/:name', (req, res) => { app.put('/tunnel/:name', (req, res) => {
const { name } = req.params; 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' }); 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 tunnel is currently connected, disconnect and reconnect with new config
if (activeTunnels.has(name)) { if (activeTunnels.has(name)) {
manualDisconnects.add(name); manualDisconnects.add(name);
handleDisconnect(name, hostConfig, false); handleDisconnect(name, tunnelConfig, false);
setTimeout(() => { setTimeout(() => {
manualDisconnects.delete(name); manualDisconnects.delete(name);
connectSSHTunnel(hostConfig, 0); connectSSHTunnel(tunnelConfig, 0);
}, 2000); }, 2000);
} }
@@ -1071,12 +1133,12 @@ app.delete('/tunnel/:name', (req, res) => {
// Disconnect if active // Disconnect if active
if (activeTunnels.has(name)) { if (activeTunnels.has(name)) {
manualDisconnects.add(name); manualDisconnects.add(name);
const hostConfig = hostConfigs.get(name) || null; const tunnelConfig = tunnelConfigs.get(name) || null;
handleDisconnect(name, hostConfig, false); handleDisconnect(name, tunnelConfig, false);
} }
// Remove from configurations // Remove from configurations
hostConfigs.delete(name); tunnelConfigs.delete(name);
res.json({ message: 'Tunnel deleted', name }); res.json({ message: 'Tunnel deleted', name });
}); });
@@ -1084,7 +1146,6 @@ app.delete('/tunnel/:name', (req, res) => {
// Start the server // Start the server
const PORT = 8083; const PORT = 8083;
app.listen(PORT, () => { app.listen(PORT, () => {
// Initialize auto-start tunnels after a short delay
setTimeout(() => { setTimeout(() => {
initializeAutoStartTunnels(); initializeAutoStartTunnels();
}, 2000); }, 2000);