Individual SSH Tunnel control
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
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, getTunnelStatuses, connectTunnel, disconnectTunnel } from "@/apps/SSH/ssh-axios";
|
import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel } from "@/apps/SSH/ssh-axios";
|
||||||
|
|
||||||
interface ConfigEditorProps {
|
interface ConfigEditorProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
@@ -39,16 +39,6 @@ interface SSHHost {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HostStatus {
|
|
||||||
connectionState?: string;
|
|
||||||
statusReason?: string;
|
|
||||||
statusErrorType?: string;
|
|
||||||
statusRetryCount?: number;
|
|
||||||
statusMaxRetries?: number;
|
|
||||||
statusNextRetryIn?: number;
|
|
||||||
statusRetryExhausted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TunnelStatus {
|
interface TunnelStatus {
|
||||||
status: string;
|
status: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -61,7 +51,8 @@ interface TunnelStatus {
|
|||||||
|
|
||||||
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
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 () => {
|
const fetchHosts = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -76,48 +67,11 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
const fetchTunnelStatuses = useCallback(async () => {
|
const fetchTunnelStatuses = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const statusData = await getTunnelStatuses();
|
const statusData = await getTunnelStatuses();
|
||||||
|
setTunnelStatuses(statusData);
|
||||||
// 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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silent error handling
|
// Silent error handling
|
||||||
}
|
}
|
||||||
}, [hosts]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
@@ -131,76 +85,66 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchTunnelStatuses]);
|
}, [fetchTunnelStatuses]);
|
||||||
|
|
||||||
const handleConnect = async (hostId: number) => {
|
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
|
||||||
const host = hosts.find(h => h.id === hostId);
|
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||||
if (!host || !host.tunnelConnections || host.tunnelConnections.length === 0) {
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||||
return;
|
|
||||||
}
|
setTunnelActions(prev => ({ ...prev, [tunnelName]: true }));
|
||||||
|
|
||||||
// Let the backend handle the status updates
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For each tunnel connection, create a tunnel configuration
|
if (action === 'connect') {
|
||||||
for (const tunnelConnection of host.tunnelConnections) {
|
|
||||||
// Find the endpoint host configuration
|
// Find the endpoint host configuration
|
||||||
const endpointHost = hosts.find(h =>
|
const endpointHost = hosts.find(h =>
|
||||||
h.name === tunnelConnection.endpointHost ||
|
h.name === tunnel.endpointHost ||
|
||||||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost
|
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!endpointHost) {
|
if (!endpointHost) {
|
||||||
continue;
|
throw new Error('Endpoint host not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create tunnel configuration
|
// Create tunnel configuration
|
||||||
const tunnelConfig = {
|
const tunnelConfig = {
|
||||||
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
|
name: tunnelName,
|
||||||
hostName: host.name || `${host.username}@${host.ip}`,
|
hostName: host.name || `${host.username}@${host.ip}`,
|
||||||
sourceIP: host.ip,
|
sourceIP: host.ip,
|
||||||
sourceSSHPort: host.port,
|
sourceSSHPort: host.port,
|
||||||
sourceUsername: host.username,
|
sourceUsername: host.username,
|
||||||
sourcePassword: host.password,
|
sourcePassword: host.authType === 'password' ? host.password : undefined,
|
||||||
sourceAuthMethod: host.authType,
|
sourceAuthMethod: host.authType,
|
||||||
sourceSSHKey: host.key,
|
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
|
||||||
sourceKeyPassword: host.keyPassword,
|
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
|
||||||
sourceKeyType: host.keyType,
|
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
|
||||||
endpointIP: endpointHost.ip,
|
endpointIP: endpointHost.ip,
|
||||||
endpointSSHPort: endpointHost.port,
|
endpointSSHPort: endpointHost.port,
|
||||||
endpointUsername: endpointHost.username,
|
endpointUsername: endpointHost.username,
|
||||||
endpointPassword: endpointHost.password,
|
endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined,
|
||||||
endpointAuthMethod: endpointHost.authType,
|
endpointAuthMethod: endpointHost.authType,
|
||||||
endpointSSHKey: endpointHost.key,
|
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
|
||||||
endpointKeyPassword: endpointHost.keyPassword,
|
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
|
||||||
endpointKeyType: endpointHost.keyType,
|
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
|
||||||
sourcePort: tunnelConnection.sourcePort,
|
sourcePort: tunnel.sourcePort,
|
||||||
endpointPort: tunnelConnection.endpointPort,
|
endpointPort: tunnel.endpointPort,
|
||||||
maxRetries: tunnelConnection.maxRetries,
|
maxRetries: tunnel.maxRetries,
|
||||||
retryInterval: tunnelConnection.retryInterval * 1000, // Convert to milliseconds
|
retryInterval: tunnel.retryInterval * 1000, // Convert to milliseconds
|
||||||
autoStart: tunnelConnection.autoStart,
|
autoStart: tunnel.autoStart,
|
||||||
isPinned: host.pin
|
isPinned: host.pin
|
||||||
};
|
};
|
||||||
|
|
||||||
await connectTunnel(tunnelConfig);
|
await connectTunnel(tunnelConfig);
|
||||||
}
|
} else if (action === 'disconnect') {
|
||||||
} 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}`;
|
|
||||||
await disconnectTunnel(tunnelName);
|
await disconnectTunnel(tunnelName);
|
||||||
|
} else if (action === 'cancel') {
|
||||||
|
await cancelTunnel(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh statuses after action
|
||||||
|
await fetchTunnelStatuses();
|
||||||
} catch (err) {
|
} 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">
|
<div className="flex-1 overflow-auto">
|
||||||
<SSHTunnelViewer
|
<SSHTunnelViewer
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
hostStatuses={hostStatuses}
|
tunnelStatuses={tunnelStatuses}
|
||||||
onConnect={handleConnect}
|
tunnelActions={tunnelActions}
|
||||||
onDisconnect={handleDisconnect}
|
onTunnelAction={handleTunnelAction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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, 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";
|
import { Badge } from "@/components/ui/badge.tsx";
|
||||||
|
|
||||||
const CONNECTION_STATES = {
|
const CONNECTION_STATES = {
|
||||||
@@ -45,64 +45,112 @@ interface SSHHost {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TunnelStatus {
|
||||||
|
status: string;
|
||||||
|
reason?: string;
|
||||||
|
errorType?: string;
|
||||||
|
retryCount?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
nextRetryIn?: number;
|
||||||
|
retryExhausted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface SSHTunnelObjectProps {
|
interface SSHTunnelObjectProps {
|
||||||
host: SSHHost;
|
host: SSHHost;
|
||||||
onConnect?: (hostId: number) => void;
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
onDisconnect?: (hostId: number) => void;
|
tunnelActions: Record<string, boolean>;
|
||||||
connectionState?: keyof typeof CONNECTION_STATES | string;
|
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<void>;
|
||||||
statusReason?: string;
|
|
||||||
statusErrorType?: string;
|
|
||||||
statusRetryCount?: number;
|
|
||||||
statusMaxRetries?: number;
|
|
||||||
statusNextRetryIn?: number;
|
|
||||||
statusRetryExhausted?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnelObject({
|
export function SSHTunnelObject({
|
||||||
host,
|
host,
|
||||||
onConnect,
|
tunnelStatuses,
|
||||||
onDisconnect,
|
tunnelActions,
|
||||||
connectionState = "DISCONNECTED",
|
onTunnelAction
|
||||||
statusReason,
|
|
||||||
statusErrorType,
|
|
||||||
statusRetryCount,
|
|
||||||
statusMaxRetries,
|
|
||||||
statusNextRetryIn,
|
|
||||||
statusRetryExhausted
|
|
||||||
}: SSHTunnelObjectProps): React.ReactElement {
|
}: SSHTunnelObjectProps): React.ReactElement {
|
||||||
const getStatusColor = (state: string) => {
|
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||||
const upperState = state.toUpperCase();
|
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||||
switch (upperState) {
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||||
case "CONNECTED":
|
return tunnelStatuses[tunnelName];
|
||||||
return "bg-green-500";
|
};
|
||||||
case "CONNECTING":
|
|
||||||
case "VERIFYING":
|
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||||
case "RETRYING":
|
if (!status) return {
|
||||||
case "WAITING":
|
icon: <WifiOff className="h-4 w-4" />,
|
||||||
return "bg-yellow-500";
|
text: 'Unknown',
|
||||||
case "FAILED":
|
color: 'text-muted-foreground',
|
||||||
return "bg-red-500";
|
bgColor: 'bg-muted/50',
|
||||||
case "UNSTABLE":
|
borderColor: 'border-border'
|
||||||
return "bg-orange-500";
|
};
|
||||||
|
|
||||||
|
// 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:
|
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 (
|
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">
|
||||||
<div className="p-3">
|
<div className="p-4">
|
||||||
{/* Host Header */}
|
{/* 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">
|
<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" />}
|
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -114,17 +162,11 @@ export function SSHTunnelObject({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{host.tags && host.tags.length > 0 && (
|
{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) => (
|
{host.tags.slice(0, 3).map((tag, index) => (
|
||||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||||
<Tag className="h-2 w-2 mr-0.5" />
|
<Tag className="h-2 w-2 mr-0.5" />
|
||||||
@@ -139,91 +181,143 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Separator className="mb-2" />
|
<Separator className="mb-3" />
|
||||||
|
|
||||||
{/* Tunnel Connections */}
|
{/* Tunnel Connections */}
|
||||||
<div className="space-y-2 mb-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium text-card-foreground">Tunnel Connections</h4>
|
<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 ? (
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-3">
|
||||||
{host.tunnelConnections.map((tunnel, index) => (
|
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||||
<div key={index} className="text-xs bg-muted/50 rounded px-2 py-1">
|
const status = getTunnelStatus(tunnelIndex);
|
||||||
<div className="flex items-center justify-between">
|
const statusDisplay = getTunnelStatusDisplay(status);
|
||||||
<span className="text-muted-foreground">Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}</span>
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||||
{tunnel.autoStart && (
|
const isActionLoading = tunnelActions[tunnelName];
|
||||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
|
||||||
Auto
|
const isConnected = statusValue === 'CONNECTED';
|
||||||
</Badge>
|
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>
|
);
|
||||||
))}
|
})}
|
||||||
</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>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { Separator } from "@/components/ui/separator.tsx";
|
|||||||
import { Input } from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
interface TunnelConnection {
|
||||||
|
sourcePort: number;
|
||||||
|
endpointPort: number;
|
||||||
|
endpointHost: string;
|
||||||
|
maxRetries: number;
|
||||||
|
retryInterval: number;
|
||||||
|
autoStart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -19,33 +28,33 @@ interface SSHHost {
|
|||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableConfigEditor: boolean;
|
enableConfigEditor: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: any[];
|
tunnelConnections: TunnelConnection[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HostStatus {
|
interface TunnelStatus {
|
||||||
connectionState?: string;
|
status: string;
|
||||||
statusReason?: string;
|
reason?: string;
|
||||||
statusErrorType?: string;
|
errorType?: string;
|
||||||
statusRetryCount?: number;
|
retryCount?: number;
|
||||||
statusMaxRetries?: number;
|
maxRetries?: number;
|
||||||
statusNextRetryIn?: number;
|
nextRetryIn?: number;
|
||||||
statusRetryExhausted?: boolean;
|
retryExhausted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SSHTunnelViewerProps {
|
interface SSHTunnelViewerProps {
|
||||||
hosts: SSHHost[];
|
hosts: SSHHost[];
|
||||||
hostStatuses?: Record<number, HostStatus>;
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
onConnect?: (hostId: number) => void;
|
tunnelActions: Record<string, boolean>;
|
||||||
onDisconnect?: (hostId: number) => void;
|
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnelViewer({
|
export function SSHTunnelViewer({
|
||||||
hosts = [],
|
hosts = [],
|
||||||
hostStatuses = {},
|
tunnelStatuses = {},
|
||||||
onConnect,
|
tunnelActions = {},
|
||||||
onDisconnect
|
onTunnelAction
|
||||||
}: SSHTunnelViewerProps): React.ReactElement {
|
}: SSHTunnelViewerProps): React.ReactElement {
|
||||||
const [searchQuery, setSearchQuery] = React.useState("");
|
const [searchQuery, setSearchQuery] = React.useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||||
@@ -56,14 +65,6 @@ export function SSHTunnelViewer({
|
|||||||
return () => clearTimeout(handler);
|
return () => clearTimeout(handler);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleConnect = (hostId: number) => {
|
|
||||||
onConnect?.(hostId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = (hostId: number) => {
|
|
||||||
onDisconnect?.(hostId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter hosts by search query
|
// Filter hosts by search query
|
||||||
const filteredHosts = React.useMemo(() => {
|
const filteredHosts = React.useMemo(() => {
|
||||||
if (!debouncedSearch.trim()) return hosts;
|
if (!debouncedSearch.trim()) return hosts;
|
||||||
@@ -169,15 +170,9 @@ export function SSHTunnelViewer({
|
|||||||
<div key={host.id} className="w-full">
|
<div key={host.id} className="w-full">
|
||||||
<SSHTunnelObject
|
<SSHTunnelObject
|
||||||
host={host}
|
host={host}
|
||||||
connectionState={hostStatuses[host.id]?.connectionState as any}
|
tunnelStatuses={tunnelStatuses}
|
||||||
statusReason={hostStatuses[host.id]?.statusReason}
|
tunnelActions={tunnelActions}
|
||||||
statusErrorType={hostStatuses[host.id]?.statusErrorType}
|
onTunnelAction={onTunnelAction}
|
||||||
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)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
|||||||
|
|
||||||
// Tunnel-related functions
|
// Tunnel-related functions
|
||||||
|
|
||||||
// Get tunnel statuses
|
// Get all tunnel statuses (per-tunnel)
|
||||||
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
||||||
try {
|
try {
|
||||||
// Determine the tunnel API URL based on environment
|
// 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> {
|
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Determine the tunnel API URL based on environment
|
// 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> {
|
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Determine the tunnel API URL based on environment
|
// 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 };
|
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)
|
// Route: Create SSH data (requires JWT)
|
||||||
// POST /ssh/host
|
// POST /ssh/host
|
||||||
router.post('/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
router.post('/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import {Client} from 'ssh2';
|
import {Client} from 'ssh2';
|
||||||
import {exec, spawn, ChildProcess} from 'child_process';
|
import {ChildProcess} from 'child_process';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: [
|
origin: '*',
|
||||||
'http://localhost:5173', // Vite dev server
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
'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',
|
|
||||||
allowedHeaders: 'Origin,X-Requested-With,Content-Type,Accept,Authorization',
|
allowedHeaders: 'Origin,X-Requested-With,Content-Type,Accept,Authorization',
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
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 countdownIntervals = new Map<string, NodeJS.Timeout>(); // tunnelName -> countdown interval
|
||||||
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
|
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
|
||||||
const remoteClosureEvents = new Map<string, number>(); // tunnelName -> count
|
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 tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
|
||||||
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess
|
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess
|
||||||
|
|
||||||
@@ -227,15 +219,29 @@ function classifyError(errorMessage: string): ErrorType {
|
|||||||
return ERROR_TYPES.UNKNOWN;
|
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
|
// Cleanup and disconnect functions
|
||||||
function cleanupTunnelResources(tunnelName: string): void {
|
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)) {
|
if (activeTunnelProcesses.has(tunnelName)) {
|
||||||
try {
|
try {
|
||||||
const proc = activeTunnelProcesses.get(tunnelName);
|
const proc = activeTunnelProcesses.get(tunnelName);
|
||||||
if (proc) {
|
if (proc) {
|
||||||
proc.kill('SIGTERM');
|
proc.kill('SIGTERM');
|
||||||
logger.info(`Killed local ssh process for tunnel '${tunnelName}' (pid: ${proc.pid})`);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, 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);
|
const conn = activeTunnels.get(tunnelName);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
conn.end();
|
conn.end();
|
||||||
logger.info(`Called conn.end() for tunnel '${tunnelName}'`);
|
|
||||||
conn.on('close', () => {
|
|
||||||
logger.info(`SSH2 Client connection closed for tunnel '${tunnelName}'`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, 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
|
// Main SSH tunnel connection function
|
||||||
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||||
const tunnelName = tunnelConfig.name;
|
const tunnelName = tunnelConfig.name;
|
||||||
|
const tunnelMarker = getTunnelMarker(tunnelName);
|
||||||
|
|
||||||
if (manualDisconnects.has(tunnelName)) {
|
if (manualDisconnects.has(tunnelName)) {
|
||||||
return;
|
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
|
// Only set status to CONNECTING if we're not already in WAITING state
|
||||||
const currentStatus = connectionStatus.get(tunnelName);
|
const currentStatus = connectionStatus.get(tunnelName);
|
||||||
|
|
||||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -841,20 +843,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
|
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
|
||||||
// For SSH key authentication, we need to create a temporary key file
|
// For SSH key authentication, we need to create a temporary key file
|
||||||
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
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 {
|
} 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) => {
|
conn.exec(tunnelCmd, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error(`Connection error for '${tunnelName}': ${err.message}`);
|
logger.error(`Connection error for '${tunnelName}': ${err.message}`);
|
||||||
|
|
||||||
try {
|
conn.end();
|
||||||
conn.end();
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
@@ -1029,7 +1027,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||||
|
|
||||||
// Validate SSH key format
|
// Validate SSH key format
|
||||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
|
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
|
||||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----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;
|
connOptions.password = tunnelConfig.sourcePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Test basic network connectivity first
|
// Test basic network connectivity first
|
||||||
const testSocket = new net.Socket();
|
const testSocket = new net.Socket();
|
||||||
testSocket.setTimeout(5000);
|
testSocket.setTimeout(5000);
|
||||||
@@ -1104,6 +1100,87 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
|
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
|
// Express API endpoints
|
||||||
app.get('/status', (req, res) => {
|
app.get('/status', (req, res) => {
|
||||||
res.json(getAllTunnelStatus());
|
res.json(getAllTunnelStatus());
|
||||||
@@ -1177,11 +1254,51 @@ app.post('/disconnect', (req, res) => {
|
|||||||
res.json({message: 'Disconnect request received', tunnelName});
|
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
|
// Auto-start functionality
|
||||||
async function initializeAutoStartTunnels(): Promise<void> {
|
async function initializeAutoStartTunnels(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Fetch hosts with auto-start tunnel connections
|
// Fetch hosts with auto-start tunnel connections from the new internal endpoint
|
||||||
const response = await axios.get('http://localhost:8081/ssh/host', {
|
const response = await axios.get('http://localhost:8081/ssh/host/internal', {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Internal-Request': '1'
|
'X-Internal-Request': '1'
|
||||||
@@ -1225,7 +1342,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
sourcePort: tunnelConnection.sourcePort,
|
sourcePort: tunnelConnection.sourcePort,
|
||||||
endpointPort: tunnelConnection.endpointPort,
|
endpointPort: tunnelConnection.endpointPort,
|
||||||
maxRetries: tunnelConnection.maxRetries,
|
maxRetries: tunnelConnection.maxRetries,
|
||||||
retryInterval: tunnelConnection.retryInterval * 1000, // Convert to milliseconds
|
retryInterval: tunnelConnection.retryInterval * 1000,
|
||||||
autoStart: tunnelConnection.autoStart,
|
autoStart: tunnelConnection.autoStart,
|
||||||
isPinned: host.pin
|
isPinned: host.pin
|
||||||
};
|
};
|
||||||
@@ -1249,72 +1366,10 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.response?.status === 401) {
|
logger.error('Failed to initialize auto-start tunnels:', error.message);
|
||||||
logger.warn('Authentication required for auto-start tunnels. Skipping auto-start initialization.');
|
|
||||||
} else {
|
|
||||||
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;
|
const PORT = 8083;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user