Individual SSH Tunnel control
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user