Individual SSH Tunnel control

This commit is contained in:
LukeGus
2025-07-27 16:04:32 -05:00
parent 32945adcd9
commit 634e625eef
6 changed files with 491 additions and 354 deletions

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from "react";
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel } from "@/apps/SSH/ssh-axios";
import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel } from "@/apps/SSH/ssh-axios";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
@@ -39,16 +39,6 @@ interface SSHHost {
updatedAt: string;
}
interface HostStatus {
connectionState?: string;
statusReason?: string;
statusErrorType?: string;
statusRetryCount?: number;
statusMaxRetries?: number;
statusNextRetryIn?: number;
statusRetryExhausted?: boolean;
}
interface TunnelStatus {
status: string;
reason?: string;
@@ -61,7 +51,8 @@ interface TunnelStatus {
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostStatuses, setHostStatuses] = useState<Record<number, HostStatus>>({});
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({}); // Track loading states
const fetchHosts = useCallback(async () => {
try {
@@ -76,48 +67,11 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
const fetchTunnelStatuses = useCallback(async () => {
try {
const statusData = await getTunnelStatuses();
// Convert tunnel statuses to host statuses
const newHostStatuses: Record<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) {
// Just use the first tunnel's status for now - simplify
const firstTunnelStatus = hostTunnelStatuses[0];
newHostStatuses[host.id] = {
connectionState: firstTunnelStatus.status,
statusReason: firstTunnelStatus.reason,
statusErrorType: firstTunnelStatus.errorType,
statusRetryCount: firstTunnelStatus.retryCount,
statusMaxRetries: firstTunnelStatus.maxRetries,
statusNextRetryIn: firstTunnelStatus.nextRetryIn,
statusRetryExhausted: firstTunnelStatus.retryExhausted,
};
} else {
// Set default disconnected status
newHostStatuses[host.id] = {
connectionState: 'disconnected'
};
}
});
setHostStatuses(newHostStatuses);
setTunnelStatuses(statusData);
} catch (err) {
// Silent error handling
}
}, [hosts]);
}, []);
useEffect(() => {
fetchHosts();
@@ -131,76 +85,66 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
const handleConnect = async (hostId: number) => {
const host = hosts.find(h => h.id === hostId);
if (!host || !host.tunnelConnections || host.tunnelConnections.length === 0) {
return;
}
// Let the backend handle the status updates
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
setTunnelActions(prev => ({ ...prev, [tunnelName]: true }));
try {
// For each tunnel connection, create a tunnel configuration
for (const tunnelConnection of host.tunnelConnections) {
if (action === 'connect') {
// Find the endpoint host configuration
const endpointHost = hosts.find(h =>
h.name === tunnelConnection.endpointHost ||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
);
if (!endpointHost) {
continue;
throw new Error('Endpoint host not found');
}
// Create tunnel configuration
const tunnelConfig = {
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
name: tunnelName,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.password,
sourcePassword: host.authType === 'password' ? host.password : undefined,
sourceAuthMethod: host.authType,
sourceSSHKey: host.key,
sourceKeyPassword: host.keyPassword,
sourceKeyType: host.keyType,
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointPassword: endpointHost.password,
endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined,
endpointAuthMethod: endpointHost.authType,
endpointSSHKey: endpointHost.key,
endpointKeyPassword: endpointHost.keyPassword,
endpointKeyType: endpointHost.keyType,
sourcePort: tunnelConnection.sourcePort,
endpointPort: tunnelConnection.endpointPort,
maxRetries: tunnelConnection.maxRetries,
retryInterval: tunnelConnection.retryInterval * 1000, // Convert to milliseconds
autoStart: tunnelConnection.autoStart,
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000, // Convert to milliseconds
autoStart: tunnel.autoStart,
isPinned: host.pin
};
await connectTunnel(tunnelConfig);
}
} catch (err) {
// Let the backend handle error status updates
}
};
const handleDisconnect = async (hostId: number) => {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
// Let the backend handle the status updates
try {
// Disconnect all tunnels for this host
for (const tunnelConnection of host.tunnelConnections) {
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`;
} else if (action === 'disconnect') {
await disconnectTunnel(tunnelName);
} else if (action === 'cancel') {
await cancelTunnel(tunnelName);
}
// Refresh statuses after action
await fetchTunnelStatuses();
} catch (err) {
// Silent error handling
console.error(`Failed to ${action} tunnel:`, err);
// Let the backend handle error status updates
} finally {
setTunnelActions(prev => ({ ...prev, [tunnelName]: false }));
}
};
@@ -214,9 +158,9 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
<div className="flex-1 overflow-auto">
<SSHTunnelViewer
hosts={hosts}
hostStatuses={hostStatuses}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Loader2, Pin, Terminal, Network, FileEdit, Tag } from "lucide-react";
import { Loader2, Pin, Terminal, Network, FileEdit, Tag, Play, Square, AlertCircle, Clock, Wifi, WifiOff, Zap, X } from "lucide-react";
import { Badge } from "@/components/ui/badge.tsx";
const CONNECTION_STATES = {
@@ -45,64 +45,112 @@ interface SSHHost {
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelObjectProps {
host: SSHHost;
onConnect?: (hostId: number) => void;
onDisconnect?: (hostId: number) => void;
connectionState?: keyof typeof CONNECTION_STATES | string;
statusReason?: string;
statusErrorType?: string;
statusRetryCount?: number;
statusMaxRetries?: number;
statusNextRetryIn?: number;
statusRetryExhausted?: boolean;
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<void>;
}
export function SSHTunnelObject({
host,
onConnect,
onDisconnect,
connectionState = "DISCONNECTED",
statusReason,
statusErrorType,
statusRetryCount,
statusMaxRetries,
statusNextRetryIn,
statusRetryExhausted
tunnelStatuses,
tunnelActions,
onTunnelAction
}: SSHTunnelObjectProps): React.ReactElement {
const getStatusColor = (state: string) => {
const upperState = state.toUpperCase();
switch (upperState) {
case "CONNECTED":
return "bg-green-500";
case "CONNECTING":
case "VERIFYING":
case "RETRYING":
case "WAITING":
return "bg-yellow-500";
case "FAILED":
return "bg-red-500";
case "UNSTABLE":
return "bg-orange-500";
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
return tunnelStatuses[tunnelName];
};
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
if (!status) return {
icon: <WifiOff className="h-4 w-4" />,
text: 'Unknown',
color: 'text-muted-foreground',
bgColor: 'bg-muted/50',
borderColor: 'border-border'
};
// Handle both the old format (status.status) and new format (status.status)
const statusValue = status.status || 'DISCONNECTED';
switch (statusValue.toUpperCase()) {
case 'CONNECTED':
return {
icon: <Wifi className="h-4 w-4" />,
text: 'Connected',
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-500/10 dark:bg-green-400/10',
borderColor: 'border-green-500/20 dark:border-green-400/20'
};
case 'CONNECTING':
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: 'Connecting...',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
};
case 'DISCONNECTING':
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: 'Disconnecting...',
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10',
borderColor: 'border-orange-500/20 dark:border-orange-400/20'
};
case 'DISCONNECTED':
return {
icon: <WifiOff className="h-4 w-4" />,
text: 'Disconnected',
color: 'text-muted-foreground',
bgColor: 'bg-muted/30',
borderColor: 'border-border'
};
case 'WAITING':
return {
icon: <Clock className="h-4 w-4" />,
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
};
case 'ERROR':
case 'FAILED':
return {
icon: <AlertCircle className="h-4 w-4" />,
text: status.reason || 'Error',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-500/10 dark:bg-red-400/10',
borderColor: 'border-red-500/20 dark:border-red-400/20'
};
default:
return "bg-gray-500";
return {
icon: <WifiOff className="h-4 w-4" />,
text: statusValue,
color: 'text-muted-foreground',
bgColor: 'bg-muted/30',
borderColor: 'border-border'
};
}
};
const getStatusText = (state: string) => {
// Just capitalize the first letter of the status from backend
return state.charAt(0).toUpperCase() + state.slice(1);
};
const isConnected = connectionState === "CONNECTED" || connectionState === "connected";
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING", "WAITING", "connecting", "verifying", "retrying", "waiting"].includes(connectionState);
const isDisconnecting = connectionState === "DISCONNECTING" || connectionState === "disconnecting";
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-3">
<div className="p-4">
{/* Host Header */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />}
<div className="flex-1 min-w-0">
@@ -114,17 +162,11 @@ export function SSHTunnelObject({
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${getStatusColor(connectionState)}`} />
<span className="text-sm text-muted-foreground whitespace-nowrap">
{getStatusText(connectionState)}
</span>
</div>
</div>
{/* Tags */}
{host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
<div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5" />
@@ -139,91 +181,143 @@ export function SSHTunnelObject({
</div>
)}
<Separator className="mb-2" />
<Separator className="mb-3" />
{/* Tunnel Connections */}
<div className="space-y-2 mb-3">
<h4 className="text-sm font-medium text-card-foreground">Tunnel Connections</h4>
<div className="space-y-3">
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4" />
Tunnel Connections ({host.tunnelConnections.length})
</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 className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
const isConnected = statusValue === 'CONNECTED';
const isConnecting = statusValue === 'CONNECTING';
const isDisconnecting = statusValue === 'DISCONNECTING';
const isRetrying = statusValue === 'RETRYING';
const isWaiting = statusValue === 'WAITING';
return (
<div key={tunnelIndex} className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
Port {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div className={`text-xs ${statusDisplay.color} font-medium`}>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{tunnel.autoStart && (
<Badge variant="outline" className="text-xs px-2 py-1">
<Zap className="h-3 w-3 mr-1" />
Auto
</Badge>
)}
{/* Action Button */}
{!isActionLoading && (
<>
{isConnected ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
className="h-8 px-3 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50"
>
<Square className="h-3 w-3 mr-1" />
Disconnect
</Button>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
className="h-8 px-3 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50"
>
<X className="h-3 w-3 mr-1" />
Cancel
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
disabled={isConnecting || isDisconnecting}
className="h-8 px-3 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50"
>
<Play className="h-3 w-3 mr-1" />
Connect
</Button>
)}
</>
)}
{isActionLoading && (
<Button
size="sm"
variant="outline"
disabled
className="h-8 px-3 text-muted-foreground border-border"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
</Button>
)}
</div>
</div>
{/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">Error:</div>
{status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && (
<>
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
Check your Docker logs for the error reason, join the <a href="https://discord.com/invite/jVQGdvHDrf" target="_blank" rel="noopener noreferrer" className="underline text-blue-600 dark:text-blue-400">Discord</a> or create a <a href="https://github.com/LukeGus/Termix/issues/new" target="_blank" rel="noopener noreferrer" className="underline text-blue-600 dark:text-blue-400">GitHub issue</a> for help.
</div>
</>
)}
</div>
)}
{/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
</div>
<div>
Attempt {status.retryCount} of {status.maxRetries}
{status.nextRetryIn && (
<span> Next retry in {status.nextRetryIn} seconds</span>
)}
</div>
</div>
)}
</div>
</div>
))}
);
})}
</div>
) : (
<p className="text-xs text-muted-foreground">No tunnel connections configured</p>
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No tunnel connections configured</p>
</div>
)}
</div>
{/* Error/Status Reason */}
{((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">
{statusReason}
{statusReason && statusReason.includes('Max retries exhausted') && (
<>
<br />
<span>
Check your Docker logs for the error reason, join the <a href="https://discord.com/invite/jVQGdvHDrf" target="_blank" rel="noopener noreferrer" className="underline text-blue-400">Discord</a> or create a <a href="https://github.com/LukeGus/Termix/issues/new" target="_blank" rel="noopener noreferrer" className="underline text-blue-400">GitHub issue</a> for help.
</span>
</>
)}
</div>
)}
{/* Retry Info */}
{(connectionState === "retrying" || connectionState === "waiting") && 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">
{connectionState === "waiting" ? "Waiting" : "Retry"} {statusRetryCount}/{statusMaxRetries}
{statusNextRetryIn && (
<span> Next retry in {statusNextRetryIn}s</span>
)}
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
<Button
onClick={() => onConnect?.(host.id)}
disabled={isConnected || isConnecting || isDisconnecting}
className="flex-1"
variant={isConnected ? "secondary" : "default"}
>
{isConnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{getStatusText(connectionState)}...
</>
) : isConnected ? (
"Connected"
) : (
"Connect"
)}
</Button>
<Button
onClick={() => onDisconnect?.(host.id)}
disabled={!isConnected || isDisconnecting || isConnecting}
variant="outline"
className="flex-1"
>
{isDisconnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Disconnecting...
</>
) : (
"Disconnect"
)}
</Button>
</div>
</div>
</Card>
);

View File

@@ -5,6 +5,15 @@ import { Separator } from "@/components/ui/separator.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Search } from "lucide-react";
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
@@ -19,33 +28,33 @@ interface SSHHost {
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface HostStatus {
connectionState?: string;
statusReason?: string;
statusErrorType?: string;
statusRetryCount?: number;
statusMaxRetries?: number;
statusNextRetryIn?: number;
statusRetryExhausted?: boolean;
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelViewerProps {
hosts: SSHHost[];
hostStatuses?: Record<number, HostStatus>;
onConnect?: (hostId: number) => void;
onDisconnect?: (hostId: number) => void;
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<void>;
}
export function SSHTunnelViewer({
hosts = [],
hostStatuses = {},
onConnect,
onDisconnect
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
@@ -56,14 +65,6 @@ export function SSHTunnelViewer({
return () => clearTimeout(handler);
}, [searchQuery]);
const handleConnect = (hostId: number) => {
onConnect?.(hostId);
};
const handleDisconnect = (hostId: number) => {
onDisconnect?.(hostId);
};
// Filter hosts by search query
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
@@ -169,15 +170,9 @@ export function SSHTunnelViewer({
<div key={host.id} className="w-full">
<SSHTunnelObject
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)}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={onTunnelAction}
/>
</div>
))}

View File

@@ -280,7 +280,7 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
// Tunnel-related functions
// Get tunnel statuses
// Get all tunnel statuses (per-tunnel)
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
try {
// Determine the tunnel API URL based on environment
@@ -293,7 +293,13 @@ export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>>
}
}
// Connect tunnel
// Get status for a specific tunnel by tunnel name
export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelStatus | undefined> {
const statuses = await getTunnelStatuses();
return statuses[tunnelName];
}
// Connect tunnel (per-tunnel)
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
try {
// Determine the tunnel API URL based on environment
@@ -306,7 +312,7 @@ export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
}
}
// Disconnect tunnel
// Disconnect tunnel (per-tunnel)
export async function disconnectTunnel(tunnelName: string): Promise<any> {
try {
// Determine the tunnel API URL based on environment
@@ -319,4 +325,16 @@ export async function disconnectTunnel(tunnelName: string): Promise<any> {
}
}
export async function cancelTunnel(tunnelName: string): Promise<any> {
try {
// Determine the tunnel API URL based on environment
const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`;
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
return response.data;
} catch (error) {
console.error('Error canceling tunnel:', error);
throw error;
}
}
export { api };

View File

@@ -83,6 +83,37 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
}
}
// Helper to check if request is from localhost
function isLocalhost(req: Request) {
const ip = req.ip || req.connection?.remoteAddress;
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
}
// Internal-only endpoint for autostart (no JWT)
router.get('/host/internal', async (req: Request, res: Response) => {
if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') {
logger.warn('Unauthorized attempt to access internal SSH host endpoint');
return res.status(403).json({ error: 'Forbidden' });
}
try {
const data = await db.select().from(sshData);
// Convert tags to array, booleans to bool, tunnelConnections to array
const result = data.map((row: any) => ({
...row,
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
pin: !!row.pin,
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
enableConfigEditor: !!row.enableConfigEditor,
}));
res.json(result);
} catch (err) {
logger.error('Failed to fetch SSH data (internal)', err);
res.status(500).json({ error: 'Failed to fetch SSH data' });
}
});
// Route: Create SSH data (requires JWT)
// POST /ssh/host
router.post('/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {

View File

@@ -1,22 +1,15 @@
import express from 'express';
import cors from 'cors';
import {Client} from 'ssh2';
import {exec, spawn, ChildProcess} from 'child_process';
import {ChildProcess} from 'child_process';
import chalk from 'chalk';
import axios from 'axios';
import * as net from 'net';
const app = express();
app.use(cors({
origin: [
'http://localhost:5173', // Vite dev server
'http://localhost:3000', // Common React dev port
'http://127.0.0.1:5173',
'http://127.0.0.1:3000',
'*', // Allow all for dev, remove in prod
],
credentials: true,
methods: 'GET,POST,PUT,DELETE,OPTIONS',
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: 'Origin,X-Requested-With,Content-Type,Accept,Authorization',
}));
app.use(express.json());
@@ -58,7 +51,6 @@ const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> re
const countdownIntervals = new Map<string, NodeJS.Timeout>(); // tunnelName -> countdown interval
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
const remoteClosureEvents = new Map<string, number>(); // tunnelName -> count
const hostConfigs = new Map<string, HostConfig>(); // hostName -> hostConfig
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess
@@ -227,15 +219,29 @@ function classifyError(errorMessage: string): ErrorType {
return ERROR_TYPES.UNKNOWN;
}
// Helper to build a unique marker for each tunnel
function getTunnelMarker(tunnelName: string) {
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
}
// Cleanup and disconnect functions
function cleanupTunnelResources(tunnelName: string): void {
// Kill any local ssh process for this tunnel
// Fire-and-forget remote pkill (do not block local cleanup)
const tunnelConfig = tunnelConfigs.get(tunnelName);
if (tunnelConfig) {
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
if (err) {
logger.error(`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`);
}
});
}
// Local cleanup (always run immediately)
if (activeTunnelProcesses.has(tunnelName)) {
try {
const proc = activeTunnelProcesses.get(tunnelName);
if (proc) {
proc.kill('SIGTERM');
logger.info(`Killed local ssh process for tunnel '${tunnelName}' (pid: ${proc.pid})`);
}
} catch (e) {
logger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e);
@@ -248,10 +254,6 @@ function cleanupTunnelResources(tunnelName: string): void {
const conn = activeTunnels.get(tunnelName);
if (conn) {
conn.end();
logger.info(`Called conn.end() for tunnel '${tunnelName}'`);
conn.on('close', () => {
logger.info(`SSH2 Client connection closed for tunnel '${tunnelName}'`);
});
}
} catch (e) {
logger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e);
@@ -711,6 +713,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
// Main SSH tunnel connection function
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
const tunnelName = tunnelConfig.name;
const tunnelMarker = getTunnelMarker(tunnelName);
if (manualDisconnects.has(tunnelName)) {
return;
@@ -728,7 +731,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
// Only set status to CONNECTING if we're not already in WAITING state
const currentStatus = connectionStatus.get(tunnelName);
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
broadcastTunnelStatus(tunnelName, {
connected: false,
@@ -841,20 +843,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
// For SSH key authentication, we need to create a temporary key file
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -4 -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`;
tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
} else {
tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -4 -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`;
}
conn.exec(tunnelCmd, (err, stream) => {
if (err) {
logger.error(`Connection error for '${tunnelName}': ${err.message}`);
try {
conn.end();
} catch (e) {
}
conn.end();
activeTunnels.delete(tunnelName);
@@ -1029,7 +1027,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
// Validate SSH key format
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
@@ -1061,7 +1058,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
connOptions.password = tunnelConfig.sourcePassword;
}
// Test basic network connectivity first
const testSocket = new net.Socket();
testSocket.setTimeout(5000);
@@ -1104,6 +1100,87 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
}
// Add a helper to kill the tunnel by marker
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
const tunnelMarker = getTunnelMarker(tunnelName);
const conn = new Client();
const connOptions: any = {
host: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
readyTimeout: 10000,
tcpKeepAlive: true,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521'
],
cipher: [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm@openssh.com',
'aes256-gcm@openssh.com',
'aes128-cbc',
'aes192-cbc',
'aes256-cbc',
'3des-cbc'
],
hmac: [
'hmac-sha2-256',
'hmac-sha2-512',
'hmac-sha1',
'hmac-md5'
],
compress: [
'none',
'zlib@openssh.com',
'zlib'
]
}
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
connOptions.privateKey = tunnelConfig.sourceSSHKey;
if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
}
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
}
} else {
connOptions.password = tunnelConfig.sourcePassword;
}
conn.on('ready', () => {
// Use pkill to kill the tunnel by marker
const killCmd = `pkill -f '${tunnelMarker}'`;
conn.exec(killCmd, (err, stream) => {
if (err) {
conn.end();
callback(err);
return;
}
stream.on('close', () => {
conn.end();
callback();
});
stream.on('data', () => {});
stream.stderr.on('data', () => {});
});
});
conn.on('error', (err) => {
callback(err);
});
conn.connect(connOptions);
}
// Express API endpoints
app.get('/status', (req, res) => {
res.json(getAllTunnelStatus());
@@ -1177,11 +1254,51 @@ app.post('/disconnect', (req, res) => {
res.json({message: 'Disconnect request received', tunnelName});
});
app.post('/cancel', (req, res) => {
const {tunnelName} = req.body;
if (!tunnelName) {
return res.status(400).json({error: 'Tunnel name required'});
}
// Cancel retry operations
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
if (activeRetryTimers.has(tunnelName)) {
clearTimeout(activeRetryTimers.get(tunnelName)!);
activeRetryTimers.delete(tunnelName);
}
if (countdownIntervals.has(tunnelName)) {
clearInterval(countdownIntervals.get(tunnelName)!);
countdownIntervals.delete(tunnelName);
}
// Set status to disconnected
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true
});
// Clean up any existing tunnel resources
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, tunnelConfig, false);
// Clear manual disconnect flag after a delay
setTimeout(() => {
manualDisconnects.delete(tunnelName);
}, 5000);
res.json({message: 'Cancel request received', tunnelName});
});
// Auto-start functionality
async function initializeAutoStartTunnels(): Promise<void> {
try {
// Fetch hosts with auto-start tunnel connections
const response = await axios.get('http://localhost:8081/ssh/host', {
// Fetch hosts with auto-start tunnel connections from the new internal endpoint
const response = await axios.get('http://localhost:8081/ssh/host/internal', {
headers: {
'Content-Type': 'application/json',
'X-Internal-Request': '1'
@@ -1225,7 +1342,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
sourcePort: tunnelConnection.sourcePort,
endpointPort: tunnelConnection.endpointPort,
maxRetries: tunnelConnection.maxRetries,
retryInterval: tunnelConnection.retryInterval * 1000, // Convert to milliseconds
retryInterval: tunnelConnection.retryInterval * 1000,
autoStart: tunnelConnection.autoStart,
isPinned: host.pin
};
@@ -1249,72 +1366,10 @@ async function initializeAutoStartTunnels(): Promise<void> {
}, 1000);
}
} catch (error: any) {
if (error.response?.status === 401) {
logger.warn('Authentication required for auto-start tunnels. Skipping auto-start initialization.');
} else {
logger.error('Failed to initialize auto-start tunnels:', error.message);
}
logger.error('Failed to initialize auto-start tunnels:', error.message);
}
}
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
activeTunnels: activeTunnels.size,
timestamp: new Date().toISOString()
});
});
// Get all tunnel configurations
app.get('/tunnels', (req, res) => {
const tunnels = Array.from(tunnelConfigs.values());
res.json(tunnels);
});
// Update tunnel configuration
app.put('/tunnel/:name', (req, res) => {
const {name} = req.params;
const tunnelConfig: TunnelConfig = req.body;
if (!tunnelConfig || !tunnelConfig.name) {
return res.status(400).json({error: 'Invalid tunnel configuration'});
}
tunnelConfigs.set(name, tunnelConfig);
// If tunnel is currently connected, disconnect and reconnect with new config
if (activeTunnels.has(name)) {
manualDisconnects.add(name);
handleDisconnect(name, tunnelConfig, false);
setTimeout(() => {
manualDisconnects.delete(name);
connectSSHTunnel(tunnelConfig, 0);
}, 2000);
}
res.json({message: 'Tunnel configuration updated', name});
});
// Delete tunnel configuration
app.delete('/tunnel/:name', (req, res) => {
const {name} = req.params;
// Disconnect if active
if (activeTunnels.has(name)) {
manualDisconnects.add(name);
const tunnelConfig = tunnelConfigs.get(name) || null;
handleDisconnect(name, tunnelConfig, false);
}
// Remove from configurations
tunnelConfigs.delete(name);
res.json({message: 'Tunnel deleted', name});
});
// Start the server
const PORT = 8083;
app.listen(PORT, () => {
setTimeout(() => {