Individual SSH Tunnel control

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

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from "react"; import 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>

View File

@@ -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>
); );

View File

@@ -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>
))} ))}

View File

@@ -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 };

View File

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

View File

@@ -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(() => {