Update ssh tunnel to new system
This commit is contained in:
@@ -471,7 +471,9 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
|||||||
filteredSshByFolder[folder] = hosts.filter(conn => {
|
filteredSshByFolder[folder] = hosts.filter(conn => {
|
||||||
const q = debouncedSearch.trim().toLowerCase();
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q);
|
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
|
||||||
|
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
|
||||||
|
(conn.tags || '').toLowerCase().includes(q);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -926,7 +928,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
|||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Search hosts..."
|
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||||
className="w-full h-8 text-sm bg-background border border-border rounded"
|
className="w-full h-8 text-sm bg-background border border-border rounded"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
Hammer
|
Hammer, Pin
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -131,10 +131,16 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
|||||||
if (!debouncedSearch.trim()) return hosts;
|
if (!debouncedSearch.trim()) return hosts;
|
||||||
const q = debouncedSearch.trim().toLowerCase();
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
return hosts.filter(h => {
|
return hosts.filter(h => {
|
||||||
const name = (h.name || "").toLowerCase();
|
const searchableText = [
|
||||||
const ip = (h.ip || "").toLowerCase();
|
h.name || '',
|
||||||
const tags = Array.isArray(h.tags) ? h.tags : [];
|
h.username,
|
||||||
return name.includes(q) || ip.includes(q) || tags.some((tag: string) => tag.toLowerCase().includes(q));
|
h.ip,
|
||||||
|
h.folder || '',
|
||||||
|
...(h.tags || []),
|
||||||
|
h.authType,
|
||||||
|
h.defaultPath || ''
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
return searchableText.includes(q);
|
||||||
});
|
});
|
||||||
}, [hosts, debouncedSearch]);
|
}, [hosts, debouncedSearch]);
|
||||||
|
|
||||||
@@ -214,7 +220,7 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
|||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Search hosts..."
|
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||||
className="w-full h-8 text-sm bg-background border border-border rounded"
|
className="w-full h-8 text-sm bg-background border border-border rounded"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
@@ -343,7 +349,9 @@ const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect }: {
|
|||||||
onClick={() => onHostConnect(host)}
|
onClick={() => onHostConnect(host)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
{host.pin && <span className="text-yellow-400 mr-1 flex-shrink-0">★</span>}
|
{host.pin &&
|
||||||
|
<Pin className="h-3.5 mr-1 w-3.5 mt-0.5 text-yellow-500 flex-shrink-0" />
|
||||||
|
}
|
||||||
<span className="font-medium truncate">{host.name || host.ip}</span>
|
<span className="font-medium truncate">{host.name || host.ip}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,90 +1,75 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
||||||
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||||
|
import { getSSHHosts } from "@/apps/SSH/ssh-axios";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
interface ConfigEditorProps {
|
interface ConfigEditorProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SSHTunnel {
|
interface TunnelConnection {
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
folder: string;
|
|
||||||
sourcePort: number;
|
sourcePort: number;
|
||||||
endpointPort: number;
|
endpointPort: number;
|
||||||
sourceIP: string;
|
endpointHost: string;
|
||||||
sourceSSHPort: number;
|
|
||||||
sourceUsername: string;
|
|
||||||
sourcePassword: string;
|
|
||||||
sourceAuthMethod: string;
|
|
||||||
sourceSSHKey: string;
|
|
||||||
sourceKeyPassword: string;
|
|
||||||
sourceKeyType: string;
|
|
||||||
endpointIP: string;
|
|
||||||
endpointSSHPort: number;
|
|
||||||
endpointUsername: string;
|
|
||||||
endpointPassword: string;
|
|
||||||
endpointAuthMethod: string;
|
|
||||||
endpointSSHKey: string;
|
|
||||||
endpointKeyPassword: string;
|
|
||||||
endpointKeyType: string;
|
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
connectionState: string;
|
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
isPinned: boolean;
|
}
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableConfigEditor: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: TunnelConnection[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostStatus {
|
||||||
|
connectionState?: string;
|
||||||
|
statusReason?: string;
|
||||||
|
statusErrorType?: string;
|
||||||
|
statusRetryCount?: number;
|
||||||
|
statusMaxRetries?: number;
|
||||||
|
statusNextRetryIn?: number;
|
||||||
|
statusRetryExhausted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TunnelStatus {
|
||||||
|
status: string;
|
||||||
|
reason?: string;
|
||||||
|
errorType?: string;
|
||||||
|
retryCount?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
nextRetryIn?: number;
|
||||||
|
retryExhausted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||||
const [tunnels, setTunnels] = useState<SSHTunnel[]>([]);
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
const [hostStatuses, setHostStatuses] = useState<Record<number, HostStatus>>({});
|
||||||
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
|
|
||||||
const [tunnelStatusMap, setTunnelStatusMap] = useState<Record<string, any>>({});
|
|
||||||
const sidebarRef = React.useRef<any>(null);
|
|
||||||
|
|
||||||
const fetchTunnels = useCallback(async () => {
|
const fetchHosts = useCallback(async () => {
|
||||||
setTunnelsLoading(true);
|
|
||||||
setTunnelsError(null);
|
|
||||||
try {
|
try {
|
||||||
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
const hostsData = await getSSHHosts();
|
||||||
const res = await axios.get(
|
setHosts(hostsData);
|
||||||
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + '/ssh_tunnel/tunnel',
|
} catch (err) {
|
||||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
// Silent error handling
|
||||||
);
|
|
||||||
const tunnelData = res.data || [];
|
|
||||||
setTunnels(tunnelData.map((tunnel: any) => ({
|
|
||||||
id: tunnel.id,
|
|
||||||
name: tunnel.name,
|
|
||||||
folder: tunnel.folder || '',
|
|
||||||
sourcePort: tunnel.sourcePort,
|
|
||||||
endpointPort: tunnel.endpointPort,
|
|
||||||
sourceIP: tunnel.sourceIP,
|
|
||||||
sourceSSHPort: tunnel.sourceSSHPort,
|
|
||||||
sourceUsername: tunnel.sourceUsername || '',
|
|
||||||
sourcePassword: tunnel.sourcePassword || '',
|
|
||||||
sourceAuthMethod: tunnel.sourceAuthMethod || 'password',
|
|
||||||
sourceSSHKey: tunnel.sourceSSHKey || '',
|
|
||||||
sourceKeyPassword: tunnel.sourceKeyPassword || '',
|
|
||||||
sourceKeyType: tunnel.sourceKeyType || '',
|
|
||||||
endpointIP: tunnel.endpointIP,
|
|
||||||
endpointSSHPort: tunnel.endpointSSHPort,
|
|
||||||
endpointUsername: tunnel.endpointUsername || '',
|
|
||||||
endpointPassword: tunnel.endpointPassword || '',
|
|
||||||
endpointAuthMethod: tunnel.endpointAuthMethod || 'password',
|
|
||||||
endpointSSHKey: tunnel.endpointSSHKey || '',
|
|
||||||
endpointKeyPassword: tunnel.endpointKeyPassword || '',
|
|
||||||
endpointKeyType: tunnel.endpointKeyType || '',
|
|
||||||
maxRetries: tunnel.maxRetries || 3,
|
|
||||||
retryInterval: tunnel.retryInterval || 5000,
|
|
||||||
connectionState: tunnel.connectionState || 'DISCONNECTED',
|
|
||||||
autoStart: tunnel.autoStart || false,
|
|
||||||
isPinned: tunnel.isPinned || false
|
|
||||||
})));
|
|
||||||
} catch (err: any) {
|
|
||||||
setTunnelsError('Failed to load tunnels');
|
|
||||||
} finally {
|
|
||||||
setTunnelsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -92,17 +77,75 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
const fetchTunnelStatuses = useCallback(async () => {
|
const fetchTunnelStatuses = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('http://localhost:8083/status');
|
const res = await axios.get('http://localhost:8083/status');
|
||||||
setTunnelStatusMap(res.data || {});
|
const statusData = res.data || {};
|
||||||
|
|
||||||
|
// Convert tunnel statuses to host statuses
|
||||||
|
const newHostStatuses: Record<number, HostStatus> = {};
|
||||||
|
|
||||||
|
hosts.forEach(host => {
|
||||||
|
// Find all tunnel statuses for this host
|
||||||
|
const hostName = host.name || `${host.username}@${host.ip}`;
|
||||||
|
const hostTunnelStatuses: TunnelStatus[] = [];
|
||||||
|
|
||||||
|
// Look for tunnel statuses that start with this host name
|
||||||
|
Object.entries(statusData).forEach(([tunnelName, status]) => {
|
||||||
|
if (tunnelName.startsWith(hostName + '_')) {
|
||||||
|
hostTunnelStatuses.push(status as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hostTunnelStatuses.length > 0) {
|
||||||
|
// Determine overall host status based on tunnel statuses
|
||||||
|
const connectedTunnels = hostTunnelStatuses.filter(s => s.status === 'connected');
|
||||||
|
const failedTunnels = hostTunnelStatuses.filter(s => s.status === 'failed');
|
||||||
|
const connectingTunnels = hostTunnelStatuses.filter(s =>
|
||||||
|
['connecting', 'verifying', 'retrying'].includes(s.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
let overallStatus: string;
|
||||||
|
let statusReason: string | undefined;
|
||||||
|
|
||||||
|
if (connectingTunnels.length > 0) {
|
||||||
|
overallStatus = 'connecting';
|
||||||
|
} else if (failedTunnels.length === hostTunnelStatuses.length) {
|
||||||
|
overallStatus = 'failed';
|
||||||
|
statusReason = failedTunnels[0]?.reason;
|
||||||
|
} else if (connectedTunnels.length === hostTunnelStatuses.length) {
|
||||||
|
overallStatus = 'connected';
|
||||||
|
} else if (connectedTunnels.length > 0) {
|
||||||
|
overallStatus = 'connected';
|
||||||
|
} else {
|
||||||
|
overallStatus = 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
newHostStatuses[host.id] = {
|
||||||
|
connectionState: overallStatus,
|
||||||
|
statusReason,
|
||||||
|
statusErrorType: failedTunnels[0]?.errorType,
|
||||||
|
statusRetryCount: connectingTunnels.find(s => s.status === 'retrying')?.retryCount,
|
||||||
|
statusMaxRetries: connectingTunnels.find(s => s.status === 'retrying')?.maxRetries,
|
||||||
|
statusNextRetryIn: connectingTunnels.find(s => s.status === 'retrying')?.nextRetryIn,
|
||||||
|
statusRetryExhausted: failedTunnels.some(s => s.retryExhausted),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Set default disconnected status
|
||||||
|
newHostStatuses[host.id] = {
|
||||||
|
connectionState: 'disconnected'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setHostStatuses(newHostStatuses);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Optionally handle error
|
// Silent error handling
|
||||||
}
|
}
|
||||||
}, []);
|
}, [hosts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTunnels();
|
fetchHosts();
|
||||||
const interval = setInterval(fetchTunnels, 10000);
|
const interval = setInterval(fetchHosts, 10000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchTunnels]);
|
}, [fetchHosts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTunnelStatuses();
|
fetchTunnelStatuses();
|
||||||
@@ -110,94 +153,88 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchTunnelStatuses]);
|
}, [fetchTunnelStatuses]);
|
||||||
|
|
||||||
// Merge backend status into tunnels
|
const handleConnect = async (hostId: number) => {
|
||||||
const tunnelsWithStatus = tunnels.map(tunnel => {
|
const host = hosts.find(h => h.id === hostId);
|
||||||
const status = tunnelStatusMap[tunnel.name] || {};
|
if (!host || !host.tunnelConnections || host.tunnelConnections.length === 0) {
|
||||||
return {
|
return;
|
||||||
...tunnel,
|
}
|
||||||
connectionState: status.status ? status.status.toUpperCase() : tunnel.connectionState,
|
|
||||||
statusReason: status.reason || '',
|
|
||||||
statusErrorType: status.errorType || '',
|
|
||||||
statusManualDisconnect: status.manualDisconnect || false,
|
|
||||||
statusRetryCount: status.retryCount,
|
|
||||||
statusMaxRetries: status.maxRetries,
|
|
||||||
statusNextRetryIn: status.nextRetryIn,
|
|
||||||
statusRetryExhausted: status.retryExhausted,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleConnect = async (tunnelId: string) => {
|
|
||||||
// Immediately set to CONNECTING for instant UI feedback
|
// Immediately set to CONNECTING for instant UI feedback
|
||||||
setTunnels(prev => prev.map(t =>
|
setHostStatuses(prev => ({
|
||||||
t.id.toString() === tunnelId
|
...prev,
|
||||||
? { ...t, connectionState: "CONNECTING" }
|
[hostId]: { ...prev[hostId], connectionState: "connecting" }
|
||||||
: t
|
}));
|
||||||
));
|
|
||||||
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
|
|
||||||
if (!tunnel) return;
|
|
||||||
try {
|
try {
|
||||||
await axios.post('http://localhost:8083/connect', {
|
// For each tunnel connection, create a tunnel configuration
|
||||||
...tunnel,
|
for (const tunnelConnection of host.tunnelConnections) {
|
||||||
name: tunnel.name
|
// Find the endpoint host configuration
|
||||||
});
|
const endpointHost = hosts.find(h =>
|
||||||
// No need to update state here; polling will update real status
|
h.name === tunnelConnection.endpointHost ||
|
||||||
|
`${h.username}@${h.ip}` === tunnelConnection.endpointHost
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!endpointHost) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tunnel configuration
|
||||||
|
const tunnelConfig = {
|
||||||
|
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
|
||||||
|
hostName: host.name || `${host.username}@${host.ip}`,
|
||||||
|
sourceIP: host.ip,
|
||||||
|
sourceSSHPort: host.port,
|
||||||
|
sourceUsername: host.username,
|
||||||
|
sourcePassword: host.password,
|
||||||
|
sourceAuthMethod: host.authType,
|
||||||
|
sourceSSHKey: host.key,
|
||||||
|
sourceKeyPassword: host.keyPassword,
|
||||||
|
sourceKeyType: host.keyType,
|
||||||
|
endpointIP: endpointHost.ip,
|
||||||
|
endpointSSHPort: endpointHost.port,
|
||||||
|
endpointUsername: endpointHost.username,
|
||||||
|
endpointPassword: endpointHost.password,
|
||||||
|
endpointAuthMethod: endpointHost.authType,
|
||||||
|
endpointSSHKey: endpointHost.key,
|
||||||
|
endpointKeyPassword: endpointHost.keyPassword,
|
||||||
|
endpointKeyType: endpointHost.keyType,
|
||||||
|
sourcePort: tunnelConnection.sourcePort,
|
||||||
|
endpointPort: tunnelConnection.endpointPort,
|
||||||
|
maxRetries: tunnelConnection.maxRetries,
|
||||||
|
retryInterval: tunnelConnection.retryInterval * 1000, // Convert to milliseconds
|
||||||
|
autoStart: tunnelConnection.autoStart,
|
||||||
|
isPinned: host.pin
|
||||||
|
};
|
||||||
|
|
||||||
|
await axios.post('http://localhost:8083/connect', tunnelConfig);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Optionally handle error
|
// Reset status on error
|
||||||
|
setHostStatuses(prev => ({
|
||||||
|
...prev,
|
||||||
|
[hostId]: { ...prev[hostId], connectionState: "failed", statusReason: "Failed to connect" }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnect = async (tunnelId: string) => {
|
const handleDisconnect = async (hostId: number) => {
|
||||||
|
const host = hosts.find(h => h.id === hostId);
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
// Immediately set to DISCONNECTING for instant UI feedback
|
// Immediately set to DISCONNECTING for instant UI feedback
|
||||||
setTunnels(prev => prev.map(t =>
|
setHostStatuses(prev => ({
|
||||||
t.id.toString() === tunnelId
|
...prev,
|
||||||
? { ...t, connectionState: "DISCONNECTING" }
|
[hostId]: { ...prev[hostId], connectionState: "disconnecting" }
|
||||||
: t
|
}));
|
||||||
));
|
|
||||||
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
|
|
||||||
if (!tunnel) return;
|
|
||||||
try {
|
try {
|
||||||
await axios.post('http://localhost:8083/disconnect', {
|
// Disconnect all tunnels for this host
|
||||||
tunnelName: tunnel.name
|
for (const tunnelConnection of host.tunnelConnections) {
|
||||||
});
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`;
|
||||||
// No need to update state here; polling will update real status
|
await axios.post('http://localhost:8083/disconnect', { tunnelName });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Optionally handle error
|
// Silent error handling
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteTunnel = async (tunnelId: string) => {
|
|
||||||
try {
|
|
||||||
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
|
||||||
await axios.delete(
|
|
||||||
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
|
|
||||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
|
||||||
);
|
|
||||||
fetchTunnels();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to delete tunnel:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditTunnel = async (tunnelId: string, data: any) => {
|
|
||||||
try {
|
|
||||||
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
|
||||||
await axios.put(
|
|
||||||
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
|
|
||||||
data,
|
|
||||||
{ headers: { Authorization: `Bearer ${jwt}` } }
|
|
||||||
);
|
|
||||||
fetchTunnels();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to edit tunnel:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditTunnelClick = (tunnelId: string) => {
|
|
||||||
// Find the tunnel data and pass it to the sidebar
|
|
||||||
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
|
|
||||||
if (tunnel && sidebarRef.current) {
|
|
||||||
// Call the sidebar's openEditSheet function
|
|
||||||
sidebarRef.current.openEditSheet(tunnel);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,19 +242,15 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
<div className="flex h-screen w-full">
|
<div className="flex h-screen w-full">
|
||||||
<div className="w-64 flex-shrink-0">
|
<div className="w-64 flex-shrink-0">
|
||||||
<SSHTunnelSidebar
|
<SSHTunnelSidebar
|
||||||
ref={sidebarRef}
|
|
||||||
onSelectView={onSelectView}
|
onSelectView={onSelectView}
|
||||||
onTunnelAdded={fetchTunnels}
|
|
||||||
onEditTunnel={handleEditTunnelClick}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<SSHTunnelViewer
|
<SSHTunnelViewer
|
||||||
tunnels={tunnelsWithStatus}
|
hosts={hosts}
|
||||||
|
hostStatuses={hostStatuses}
|
||||||
onConnect={handleConnect}
|
onConnect={handleConnect}
|
||||||
onDisconnect={handleDisconnect}
|
onDisconnect={handleDisconnect}
|
||||||
onDeleteTunnel={handleDeleteTunnel}
|
|
||||||
onEditTunnel={handleEditTunnelClick}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React from "react";
|
|||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { Loader2, Edit, Trash2 } from "lucide-react";
|
import { Loader2, Pin, Terminal, Network, FileEdit, Tag } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge.tsx";
|
||||||
|
|
||||||
const CONNECTION_STATES = {
|
const CONNECTION_STATES = {
|
||||||
DISCONNECTED: "disconnected",
|
DISCONNECTED: "disconnected",
|
||||||
@@ -15,27 +16,62 @@ const CONNECTION_STATES = {
|
|||||||
DISCONNECTING: "disconnecting"
|
DISCONNECTING: "disconnecting"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface TunnelConnection {
|
||||||
|
sourcePort: number;
|
||||||
|
endpointPort: number;
|
||||||
|
endpointHost: string;
|
||||||
|
maxRetries: number;
|
||||||
|
retryInterval: number;
|
||||||
|
autoStart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableConfigEditor: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: TunnelConnection[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SSHTunnelObjectProps {
|
interface SSHTunnelObjectProps {
|
||||||
hostConfig: any;
|
host: SSHHost;
|
||||||
onConnect?: () => void;
|
onConnect?: (hostId: number) => void;
|
||||||
onDisconnect?: () => void;
|
onDisconnect?: (hostId: number) => void;
|
||||||
onDelete?: () => void;
|
connectionState?: keyof typeof CONNECTION_STATES | string;
|
||||||
onEdit?: () => void;
|
statusReason?: string;
|
||||||
connectionState?: keyof typeof CONNECTION_STATES;
|
statusErrorType?: string;
|
||||||
isPinned?: boolean;
|
statusRetryCount?: number;
|
||||||
|
statusMaxRetries?: number;
|
||||||
|
statusNextRetryIn?: number;
|
||||||
|
statusRetryExhausted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnelObject({
|
export function SSHTunnelObject({
|
||||||
hostConfig = {},
|
host,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
onDelete,
|
|
||||||
onEdit,
|
|
||||||
connectionState = "DISCONNECTED",
|
connectionState = "DISCONNECTED",
|
||||||
isPinned = false
|
statusReason,
|
||||||
|
statusErrorType,
|
||||||
|
statusRetryCount,
|
||||||
|
statusMaxRetries,
|
||||||
|
statusNextRetryIn,
|
||||||
|
statusRetryExhausted
|
||||||
}: SSHTunnelObjectProps): React.ReactElement {
|
}: SSHTunnelObjectProps): React.ReactElement {
|
||||||
const getStatusColor = (state: keyof typeof CONNECTION_STATES) => {
|
const getStatusColor = (state: string) => {
|
||||||
switch (state) {
|
const upperState = state.toUpperCase();
|
||||||
|
switch (upperState) {
|
||||||
case "CONNECTED":
|
case "CONNECTED":
|
||||||
return "bg-green-500";
|
return "bg-green-500";
|
||||||
case "CONNECTING":
|
case "CONNECTING":
|
||||||
@@ -51,8 +87,9 @@ export function SSHTunnelObject({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (state: keyof typeof CONNECTION_STATES) => {
|
const getStatusText = (state: string) => {
|
||||||
switch (state) {
|
const upperState = state.toUpperCase();
|
||||||
|
switch (upperState) {
|
||||||
case "CONNECTED":
|
case "CONNECTED":
|
||||||
return "Connected";
|
return "Connected";
|
||||||
case "CONNECTING":
|
case "CONNECTING":
|
||||||
@@ -70,43 +107,26 @@ export function SSHTunnelObject({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isConnected = connectionState === "CONNECTED" || connectionState === "connected";
|
||||||
|
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING", "connecting", "verifying", "retrying"].includes(connectionState);
|
||||||
const isConnected = connectionState === "CONNECTED";
|
const isDisconnecting = connectionState === "DISCONNECTING" || connectionState === "disconnecting";
|
||||||
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING"].includes(connectionState);
|
|
||||||
const isDisconnecting = connectionState === "DISCONNECTING";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||||
{/* Hover overlay buttons */}
|
<div className="p-3">
|
||||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10 flex gap-1">
|
{/* Host Header */}
|
||||||
<Button
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
size="sm"
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
variant="secondary"
|
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />}
|
||||||
className="h-8 w-8 p-0 bg-black/50 hover:bg-black/70 border-0"
|
<div className="flex-1 min-w-0">
|
||||||
onClick={onEdit}
|
<h3 className="font-semibold text-card-foreground truncate">
|
||||||
>
|
{host.name || `${host.username}@${host.ip}`}
|
||||||
<Edit className="w-4 h-4" />
|
</h3>
|
||||||
</Button>
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
<Button
|
{host.ip}:{host.port} • {host.username}
|
||||||
size="sm"
|
</p>
|
||||||
variant="destructive"
|
</div>
|
||||||
className="h-8 w-8 p-0 bg-red-500/50 hover:bg-red-500/70 border-0"
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-center justify-between gap-1 mb-1">
|
|
||||||
<div className="text-lg font-semibold text-card-foreground flex-1 min-w-0">
|
|
||||||
<span className="break-words">
|
|
||||||
{isPinned && <span className="text-yellow-400 mr-1 flex-shrink-0">★</span>}
|
|
||||||
{hostConfig.name || "My SSH Tunnel"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-4 bg-border mx-1"></div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<div className={`w-2 h-2 rounded-full ${getStatusColor(connectionState)}`} />
|
<div className={`w-2 h-2 rounded-full ${getStatusColor(connectionState)}`} />
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
@@ -115,28 +135,53 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="mb-1" />
|
{/* Tags */}
|
||||||
<div className="space-y-1 mb-2">
|
{host.tags && host.tags.length > 0 && (
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
<span className="text-muted-foreground flex-shrink-0 mr-2">Source:</span>
|
{host.tags.slice(0, 3).map((tag, index) => (
|
||||||
<span className="text-card-foreground font-mono text-right break-all">
|
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||||
{hostConfig.source || "localhost:22"}
|
<Tag className="h-2 w-2 mr-0.5" />
|
||||||
</span>
|
{tag}
|
||||||
</div>
|
</Badge>
|
||||||
<div className="flex items-center justify-between text-sm">
|
))}
|
||||||
<span className="text-muted-foreground flex-shrink-0 mr-2">Endpoint:</span>
|
{host.tags.length > 3 && (
|
||||||
<span className="text-card-foreground font-mono text-right break-all">
|
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||||
{hostConfig.endpoint || "test:224"}
|
+{host.tags.length - 3}
|
||||||
</span>
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator className="mb-2" />
|
||||||
|
|
||||||
|
{/* Tunnel Connections */}
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
<h4 className="text-sm font-medium text-card-foreground">Tunnel Connections</h4>
|
||||||
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{host.tunnelConnections.map((tunnel, index) => (
|
||||||
|
<div key={index} className="text-xs bg-muted/50 rounded px-2 py-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}</span>
|
||||||
|
{tunnel.autoStart && (
|
||||||
|
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||||
|
Auto
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No tunnel connections configured</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="my-1" />
|
|
||||||
{/* Error/Status Reason */}
|
{/* Error/Status Reason */}
|
||||||
{((connectionState === "FAILED" || connectionState === "UNSTABLE") && hostConfig.statusReason) && (
|
{((connectionState === "FAILED" || connectionState === "UNSTABLE") && statusReason) && (
|
||||||
<div className="mb-2 text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
|
<div className="mb-2 text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
|
||||||
{hostConfig.statusReason}
|
{statusReason}
|
||||||
{typeof hostConfig.statusReason === 'string' && hostConfig.statusReason.includes('Max retries exhausted') && (
|
{statusReason && statusReason.includes('Max retries exhausted') && (
|
||||||
<>
|
<>
|
||||||
<br />
|
<br />
|
||||||
<span>
|
<span>
|
||||||
@@ -146,9 +191,21 @@ export function SSHTunnelObject({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
|
{/* Retry Info */}
|
||||||
|
{connectionState === "RETRYING" && statusRetryCount && statusMaxRetries && (
|
||||||
|
<div className="mb-2 text-xs text-yellow-600 bg-yellow-500/10 rounded px-2 py-1 border border-yellow-500/20">
|
||||||
|
Retry {statusRetryCount}/{statusMaxRetries}
|
||||||
|
{statusNextRetryIn && (
|
||||||
|
<span> • Next retry in {statusNextRetryIn}s</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={onConnect}
|
onClick={() => onConnect?.(host.id)}
|
||||||
disabled={isConnected || isConnecting || isDisconnecting}
|
disabled={isConnected || isConnecting || isDisconnecting}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
variant={isConnected ? "secondary" : "default"}
|
variant={isConnected ? "secondary" : "default"}
|
||||||
@@ -165,7 +222,7 @@ export function SSHTunnelObject({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onDisconnect}
|
onClick={() => onDisconnect?.(host.id)}
|
||||||
disabled={!isConnected || isDisconnecting || isConnecting}
|
disabled={!isConnected || isDisconnecting || isConnecting}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,81 +2,120 @@ import React from "react";
|
|||||||
import { SSHTunnelObject } from "./SSHTunnelObject.tsx";
|
import { SSHTunnelObject } from "./SSHTunnelObject.tsx";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx";
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableConfigEditor: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostStatus {
|
||||||
|
connectionState?: string;
|
||||||
|
statusReason?: string;
|
||||||
|
statusErrorType?: string;
|
||||||
|
statusRetryCount?: number;
|
||||||
|
statusMaxRetries?: number;
|
||||||
|
statusNextRetryIn?: number;
|
||||||
|
statusRetryExhausted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface SSHTunnelViewerProps {
|
interface SSHTunnelViewerProps {
|
||||||
tunnels: Array<{
|
hosts: SSHHost[];
|
||||||
id: number;
|
hostStatuses?: Record<number, HostStatus>;
|
||||||
name: string;
|
onConnect?: (hostId: number) => void;
|
||||||
folder: string;
|
onDisconnect?: (hostId: number) => void;
|
||||||
sourcePort: number;
|
|
||||||
endpointPort: number;
|
|
||||||
sourceIP: string;
|
|
||||||
sourceSSHPort: number;
|
|
||||||
sourceUsername: string;
|
|
||||||
sourcePassword: string;
|
|
||||||
sourceAuthMethod: string;
|
|
||||||
sourceSSHKey: string;
|
|
||||||
sourceKeyPassword: string;
|
|
||||||
sourceKeyType: string;
|
|
||||||
endpointIP: string;
|
|
||||||
endpointSSHPort: number;
|
|
||||||
endpointUsername: string;
|
|
||||||
endpointPassword: string;
|
|
||||||
endpointAuthMethod: string;
|
|
||||||
endpointSSHKey: string;
|
|
||||||
endpointKeyPassword: string;
|
|
||||||
endpointKeyType: string;
|
|
||||||
maxRetries: number;
|
|
||||||
retryInterval: number;
|
|
||||||
connectionState?: string;
|
|
||||||
autoStart: boolean;
|
|
||||||
isPinned: boolean;
|
|
||||||
}>;
|
|
||||||
onConnect?: (tunnelId: string) => void;
|
|
||||||
onDisconnect?: (tunnelId: string) => void;
|
|
||||||
onDeleteTunnel?: (tunnelId: string) => void;
|
|
||||||
onEditTunnel?: (tunnelId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnelViewer({
|
export function SSHTunnelViewer({
|
||||||
tunnels = [],
|
hosts = [],
|
||||||
onConnect,
|
hostStatuses = {},
|
||||||
onDisconnect,
|
onConnect,
|
||||||
onDeleteTunnel,
|
onDisconnect
|
||||||
onEditTunnel
|
|
||||||
}: SSHTunnelViewerProps): React.ReactElement {
|
}: SSHTunnelViewerProps): React.ReactElement {
|
||||||
const handleConnect = (tunnelId: string) => {
|
const [searchQuery, setSearchQuery] = React.useState("");
|
||||||
onConnect?.(tunnelId);
|
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleConnect = (hostId: number) => {
|
||||||
|
onConnect?.(hostId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnect = (tunnelId: string) => {
|
const handleDisconnect = (hostId: number) => {
|
||||||
onDisconnect?.(tunnelId);
|
onDisconnect?.(hostId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group tunnels by folder and sort
|
// Filter hosts by search query
|
||||||
const tunnelsByFolder = React.useMemo(() => {
|
const filteredHosts = React.useMemo(() => {
|
||||||
const map: Record<string, typeof tunnels> = {};
|
if (!debouncedSearch.trim()) return hosts;
|
||||||
tunnels.forEach(tunnel => {
|
|
||||||
const folder = tunnel.folder && tunnel.folder.trim() ? tunnel.folder : 'No Folder';
|
const query = debouncedSearch.trim().toLowerCase();
|
||||||
|
return hosts.filter(host => {
|
||||||
|
const searchableText = [
|
||||||
|
host.name || '',
|
||||||
|
host.username,
|
||||||
|
host.ip,
|
||||||
|
host.folder || '',
|
||||||
|
...(host.tags || []),
|
||||||
|
host.authType,
|
||||||
|
host.defaultPath || ''
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
return searchableText.includes(query);
|
||||||
|
});
|
||||||
|
}, [hosts, debouncedSearch]);
|
||||||
|
|
||||||
|
// Filter hosts to only show those with enableTunnel: true and tunnelConnections
|
||||||
|
const tunnelHosts = React.useMemo(() => {
|
||||||
|
return filteredHosts.filter(host =>
|
||||||
|
host.enableTunnel &&
|
||||||
|
host.tunnelConnections &&
|
||||||
|
host.tunnelConnections.length > 0
|
||||||
|
);
|
||||||
|
}, [filteredHosts]);
|
||||||
|
|
||||||
|
// Group hosts by folder and sort
|
||||||
|
const hostsByFolder = React.useMemo(() => {
|
||||||
|
const map: Record<string, SSHHost[]> = {};
|
||||||
|
tunnelHosts.forEach(host => {
|
||||||
|
const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
|
||||||
if (!map[folder]) map[folder] = [];
|
if (!map[folder]) map[folder] = [];
|
||||||
map[folder].push(tunnel);
|
map[folder].push(host);
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [tunnels]);
|
}, [tunnelHosts]);
|
||||||
|
|
||||||
const sortedFolders = React.useMemo(() => {
|
const sortedFolders = React.useMemo(() => {
|
||||||
const folders = Object.keys(tunnelsByFolder);
|
const folders = Object.keys(hostsByFolder);
|
||||||
folders.sort((a, b) => {
|
folders.sort((a, b) => {
|
||||||
if (a === 'No Folder') return -1;
|
if (a === 'Uncategorized') return -1;
|
||||||
if (b === 'No Folder') return 1;
|
if (b === 'Uncategorized') return 1;
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
});
|
});
|
||||||
return folders;
|
return folders;
|
||||||
}, [tunnelsByFolder]);
|
}, [hostsByFolder]);
|
||||||
|
|
||||||
const getSortedTunnels = (arr: typeof tunnels) => {
|
const getSortedHosts = (arr: SSHHost[]) => {
|
||||||
const pinned = arr.filter(t => t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
const rest = arr.filter(t => !t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
return [...pinned, ...rest];
|
return [...pinned, ...rest];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,14 +132,28 @@ export function SSHTunnelViewer({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Accordion Layout */}
|
{/* Accordion Layout */}
|
||||||
{tunnels.length === 0 ? (
|
{tunnelHosts.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
No SSH Tunnels
|
No SSH Tunnels
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground max-w-md">
|
<p className="text-muted-foreground max-w-md">
|
||||||
Create your first SSH tunnel to get started. Use the sidebar to add a new tunnel configuration.
|
{searchQuery.trim() ?
|
||||||
|
"No hosts match your search criteria." :
|
||||||
|
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -112,16 +165,19 @@ export function SSHTunnelViewer({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||||
<div className="grid grid-cols-4 gap-6 w-full">
|
<div className="grid grid-cols-4 gap-6 w-full">
|
||||||
{getSortedTunnels(tunnelsByFolder[folder]).map((tunnel, tunnelIndex) => (
|
{getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
|
||||||
<div key={tunnel.id} className="w-full">
|
<div key={host.id} className="w-full">
|
||||||
<SSHTunnelObject
|
<SSHTunnelObject
|
||||||
hostConfig={tunnel}
|
host={host}
|
||||||
connectionState={tunnel.connectionState as any}
|
connectionState={hostStatuses[host.id]?.connectionState as any}
|
||||||
isPinned={tunnel.isPinned}
|
statusReason={hostStatuses[host.id]?.statusReason}
|
||||||
onConnect={() => handleConnect(tunnel.id.toString())}
|
statusErrorType={hostStatuses[host.id]?.statusErrorType}
|
||||||
onDisconnect={() => handleDisconnect(tunnel.id.toString())}
|
statusRetryCount={hostStatuses[host.id]?.statusRetryCount}
|
||||||
onDelete={() => onDeleteTunnel?.(tunnel.id.toString())}
|
statusMaxRetries={hostStatuses[host.id]?.statusMaxRetries}
|
||||||
onEdit={() => onEditTunnel?.(tunnel.id.toString())}
|
statusNextRetryIn={hostStatuses[host.id]?.statusNextRetryIn}
|
||||||
|
statusRetryExhausted={hostStatuses[host.id]?.statusRetryExhausted}
|
||||||
|
onConnect={() => handleConnect(host.id)}
|
||||||
|
onDisconnect={() => handleDisconnect(host.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -46,21 +46,55 @@ const logger = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// State management
|
// State management for host-based tunnels
|
||||||
const activeTunnels = new Map<string, Client>();
|
const activeTunnels = new Map<string, Client>(); // tunnelName -> Client
|
||||||
const retryCounters = new Map<string, number>();
|
const retryCounters = new Map<string, number>(); // tunnelName -> retryCount
|
||||||
const connectionStatus = new Map<string, TunnelStatus>();
|
const connectionStatus = new Map<string, TunnelStatus>(); // tunnelName -> status
|
||||||
const tunnelVerifications = new Map<string, VerificationData>();
|
const tunnelVerifications = new Map<string, VerificationData>(); // tunnelName -> verification
|
||||||
const manualDisconnects = new Set<string>();
|
const manualDisconnects = new Set<string>(); // tunnelNames
|
||||||
const verificationTimers = new Map<string, NodeJS.Timeout>();
|
const verificationTimers = new Map<string, NodeJS.Timeout>(); // timer keys -> timeout
|
||||||
const activeRetryTimers = new Map<string, NodeJS.Timeout>();
|
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer
|
||||||
const retryExhaustedTunnels = new Set<string>();
|
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
|
||||||
const remoteClosureEvents = new Map<string, number>();
|
const remoteClosureEvents = new Map<string, number>(); // tunnelName -> count
|
||||||
const hostConfigs = new Map<string, HostConfig>();
|
const hostConfigs = new Map<string, HostConfig>(); // hostName -> hostConfig
|
||||||
|
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface HostConfig {
|
interface TunnelConnection {
|
||||||
|
sourcePort: number;
|
||||||
|
endpointPort: number;
|
||||||
|
endpointHost: string;
|
||||||
|
maxRetries: number;
|
||||||
|
retryInterval: number;
|
||||||
|
autoStart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSHHost {
|
||||||
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableConfigEditor: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: TunnelConnection[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TunnelConfig {
|
||||||
|
name: string;
|
||||||
|
hostName: string;
|
||||||
sourceIP: string;
|
sourceIP: string;
|
||||||
sourceSSHPort: number;
|
sourceSSHPort: number;
|
||||||
sourceUsername: string;
|
sourceUsername: string;
|
||||||
@@ -85,6 +119,11 @@ interface HostConfig {
|
|||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HostConfig {
|
||||||
|
host: SSHHost;
|
||||||
|
tunnels: TunnelConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
interface TunnelStatus {
|
interface TunnelStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
status: ConnectionState;
|
status: ConnectionState;
|
||||||
@@ -135,8 +174,6 @@ function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
|
|||||||
status.reason = "Max retries exhausted";
|
status.reason = "Max retries exhausted";
|
||||||
}
|
}
|
||||||
|
|
||||||
// In Express, we'll use a different approach for broadcasting
|
|
||||||
// For now, we'll store the status and provide endpoints to fetch it
|
|
||||||
connectionStatus.set(tunnelName, status);
|
connectionStatus.set(tunnelName, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +281,7 @@ function resetRetryState(tunnelName: string): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, shouldRetry = true, isRemoteClosure = false): void {
|
function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, shouldRetry = true, isRemoteClosure = false): void {
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
if (tunnelVerifications.has(tunnelName)) {
|
||||||
try {
|
try {
|
||||||
const verification = tunnelVerifications.get(tunnelName);
|
const verification = tunnelVerifications.get(tunnelName);
|
||||||
@@ -299,9 +336,9 @@ function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, sho
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRetry && hostConfig) {
|
if (shouldRetry && tunnelConfig) {
|
||||||
const maxRetries = hostConfig.maxRetries || 3;
|
const maxRetries = tunnelConfig.maxRetries || 3;
|
||||||
const retryInterval = hostConfig.retryInterval || 5000;
|
const retryInterval = tunnelConfig.retryInterval || 5000;
|
||||||
|
|
||||||
if (isRemoteClosure) {
|
if (isRemoteClosure) {
|
||||||
const currentCount = remoteClosureEvents.get(tunnelName) || 0;
|
const currentCount = remoteClosureEvents.get(tunnelName) || 0;
|
||||||
@@ -351,7 +388,7 @@ function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, sho
|
|||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName)) {
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
connectSSHTunnel(hostConfig, retryCount);
|
connectSSHTunnel(tunnelConfig, retryCount);
|
||||||
}
|
}
|
||||||
}, retryInterval);
|
}, retryInterval);
|
||||||
|
|
||||||
@@ -368,7 +405,7 @@ function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, sho
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tunnel verification function
|
// Tunnel verification function
|
||||||
function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPeriodic = false): void {
|
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
||||||
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
|
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -411,7 +448,7 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isPeriodic) {
|
if (!isPeriodic) {
|
||||||
setupPingInterval(tunnelName, hostConfig);
|
setupPingInterval(tunnelName, tunnelConfig);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Verification failed for '${tunnelName}': ${failureReason}`);
|
logger.error(`Verification failed for '${tunnelName}': ${failureReason}`);
|
||||||
@@ -425,12 +462,12 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attemptVerification() {
|
function attemptVerification() {
|
||||||
const testCmd = `nc -z localhost ${hostConfig.sourcePort}`;
|
const testCmd = `nc -z localhost ${tunnelConfig.sourcePort}`;
|
||||||
|
|
||||||
verificationConn.exec(testCmd, (err, stream) => {
|
verificationConn.exec(testCmd, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -447,7 +484,7 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe
|
|||||||
if (code === 0 && code !== undefined) {
|
if (code === 0 && code !== undefined) {
|
||||||
cleanupVerification(true);
|
cleanupVerification(true);
|
||||||
} else {
|
} else {
|
||||||
cleanupVerification(false, `Port ${hostConfig.sourcePort} is not accessible`);
|
cleanupVerification(false, `Port ${tunnelConfig.sourcePort} is not accessible`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,9 +509,9 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe
|
|||||||
});
|
});
|
||||||
|
|
||||||
const connOptions: any = {
|
const connOptions: any = {
|
||||||
host: hostConfig.sourceIP,
|
host: tunnelConfig.sourceIP,
|
||||||
port: hostConfig.sourceSSHPort,
|
port: tunnelConfig.sourceSSHPort,
|
||||||
username: hostConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
readyTimeout: 10000,
|
readyTimeout: 10000,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
@@ -512,19 +549,19 @@ function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hostConfig.sourceAuthMethod === "key" && hostConfig.sourceSSHKey) {
|
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||||
connOptions.privateKey = hostConfig.sourceSSHKey;
|
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
||||||
if (hostConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = hostConfig.sourceKeyPassword;
|
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
connOptions.password = hostConfig.sourcePassword;
|
connOptions.password = tunnelConfig.sourcePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
verificationConn.connect(connOptions);
|
verificationConn.connect(connOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void {
|
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
|
if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
@@ -550,7 +587,7 @@ function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +604,7 @@ function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -583,15 +620,15 @@ function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, 30000); // Ping every 30 seconds
|
}, 30000); // Ping every 30 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main SSH tunnel connection function
|
// Main SSH tunnel connection function
|
||||||
function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||||
const tunnelName = hostConfig.name;
|
const tunnelName = tunnelConfig.name;
|
||||||
|
|
||||||
if (manualDisconnects.has(tunnelName)) {
|
if (manualDisconnects.has(tunnelName)) {
|
||||||
return;
|
return;
|
||||||
@@ -614,7 +651,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
isRemoteRetry: !!isRetryAfterRemoteClosure
|
isRemoteRetry: !!isRetryAfterRemoteClosure
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hostConfig || !hostConfig.sourceIP || !hostConfig.sourceUsername || !hostConfig.sourceSSHPort) {
|
if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) {
|
||||||
logger.error(`Invalid connection details for '${tunnelName}'`);
|
logger.error(`Invalid connection details for '${tunnelName}'`);
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -639,7 +676,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
if (!activeRetryTimers.has(tunnelName)) {
|
if (!activeRetryTimers.has(tunnelName)) {
|
||||||
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
@@ -679,7 +716,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
manualDisconnects.has(tunnelName)
|
manualDisconnects.has(tunnelName)
|
||||||
);
|
);
|
||||||
|
|
||||||
handleDisconnect(tunnelName, hostConfig, !shouldNotRetry, isRemoteHostClosure);
|
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry, isRemoteHostClosure);
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on("close", () => {
|
conn.on("close", () => {
|
||||||
@@ -699,7 +736,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!activeRetryTimers.has(tunnelName)) {
|
if (!activeRetryTimers.has(tunnelName)) {
|
||||||
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -713,10 +750,10 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tunnelCmd: string;
|
let tunnelCmd: string;
|
||||||
if (hostConfig.endpointAuthMethod === "key" && hostConfig.endpointSSHKey) {
|
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
|
||||||
tunnelCmd = `ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${hostConfig.endpointPort}:localhost:${hostConfig.sourcePort} ${hostConfig.endpointUsername}@${hostConfig.endpointIP}`;
|
tunnelCmd = `ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
|
||||||
} else {
|
} else {
|
||||||
tunnelCmd = `sshpass -p '${hostConfig.endpointPassword || ''}' ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${hostConfig.endpointPort}:localhost:${hostConfig.sourcePort} ${hostConfig.endpointUsername}@${hostConfig.endpointIP}`;
|
tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.exec(tunnelCmd, (err, stream) => {
|
conn.exec(tunnelCmd, (err, stream) => {
|
||||||
@@ -732,7 +769,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
errorType === ERROR_TYPES.PORT ||
|
errorType === ERROR_TYPES.PORT ||
|
||||||
errorType === ERROR_TYPES.PERMISSION;
|
errorType === ERROR_TYPES.PERMISSION;
|
||||||
|
|
||||||
handleDisconnect(tunnelName, hostConfig, !shouldNotRetry);
|
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,7 +777,7 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
|
||||||
verifyTunnelConnection(tunnelName, hostConfig, false);
|
verifyTunnelConnection(tunnelName, tunnelConfig, false);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
@@ -783,11 +820,11 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!activeRetryTimers.has(tunnelName) && !retryExhaustedTunnels.has(tunnelName)) {
|
if (!activeRetryTimers.has(tunnelName) && !retryExhaustedTunnels.has(tunnelName)) {
|
||||||
handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName), isLikelyRemoteClosure);
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName), isLikelyRemoteClosure);
|
||||||
} else if (retryExhaustedTunnels.has(tunnelName) && isLikelyRemoteClosure) {
|
} else if (retryExhaustedTunnels.has(tunnelName) && isLikelyRemoteClosure) {
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
retryExhaustedTunnels.delete(tunnelName);
|
||||||
retryCounters.delete(tunnelName);
|
retryCounters.delete(tunnelName);
|
||||||
handleDisconnect(tunnelName, hostConfig, true, true);
|
handleDisconnect(tunnelName, tunnelConfig, true, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -835,16 +872,16 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
errorType === ERROR_TYPES.PERMISSION
|
errorType === ERROR_TYPES.PERMISSION
|
||||||
);
|
);
|
||||||
|
|
||||||
handleDisconnect(tunnelName, hostConfig, !shouldNotRetry, isRemoteHostClosure);
|
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry, isRemoteHostClosure);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const connOptions: any = {
|
const connOptions: any = {
|
||||||
host: hostConfig.sourceIP,
|
host: tunnelConfig.sourceIP,
|
||||||
port: hostConfig.sourceSSHPort,
|
port: tunnelConfig.sourceSSHPort,
|
||||||
username: hostConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
keepaliveInterval: 5000,
|
keepaliveInterval: 5000,
|
||||||
keepaliveCountMax: 10,
|
keepaliveCountMax: 10,
|
||||||
readyTimeout: 10000,
|
readyTimeout: 10000,
|
||||||
@@ -885,13 +922,13 @@ function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hostConfig.sourceAuthMethod === "key" && hostConfig.sourceSSHKey) {
|
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||||
connOptions.privateKey = hostConfig.sourceSSHKey;
|
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
||||||
if (hostConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = hostConfig.sourceKeyPassword;
|
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
connOptions.password = hostConfig.sourcePassword;
|
connOptions.password = tunnelConfig.sourcePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.connect(connOptions);
|
conn.connect(connOptions);
|
||||||
@@ -914,24 +951,24 @@ app.get('/status/:tunnelName', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/connect', (req, res) => {
|
app.post('/connect', (req, res) => {
|
||||||
const hostConfig: HostConfig = req.body;
|
const tunnelConfig: TunnelConfig = req.body;
|
||||||
|
|
||||||
if (!hostConfig || !hostConfig.name) {
|
if (!tunnelConfig || !tunnelConfig.name) {
|
||||||
return res.status(400).json({ error: 'Invalid tunnel configuration' });
|
return res.status(400).json({ error: 'Invalid tunnel configuration' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tunnelName = hostConfig.name;
|
const tunnelName = tunnelConfig.name;
|
||||||
|
|
||||||
// Reset retry state for new connection
|
// Reset retry state for new connection
|
||||||
manualDisconnects.delete(tunnelName);
|
manualDisconnects.delete(tunnelName);
|
||||||
retryCounters.delete(tunnelName);
|
retryCounters.delete(tunnelName);
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
retryExhaustedTunnels.delete(tunnelName);
|
||||||
|
|
||||||
// Store host config
|
// Store tunnel config
|
||||||
hostConfigs.set(tunnelName, hostConfig);
|
tunnelConfigs.set(tunnelName, tunnelConfig);
|
||||||
|
|
||||||
// Start connection
|
// Start connection
|
||||||
connectSSHTunnel(hostConfig, 0);
|
connectSSHTunnel(tunnelConfig, 0);
|
||||||
|
|
||||||
res.json({ message: 'Connection request received', tunnelName });
|
res.json({ message: 'Connection request received', tunnelName });
|
||||||
});
|
});
|
||||||
@@ -958,8 +995,8 @@ app.post('/disconnect', (req, res) => {
|
|||||||
manualDisconnect: true
|
manualDisconnect: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const hostConfig = hostConfigs.get(tunnelName) || null;
|
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||||
handleDisconnect(tunnelName, hostConfig, false);
|
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||||
|
|
||||||
// Clear manual disconnect flag after a delay
|
// Clear manual disconnect flag after a delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -972,55 +1009,80 @@ app.post('/disconnect', (req, res) => {
|
|||||||
// Auto-start functionality
|
// Auto-start functionality
|
||||||
async function initializeAutoStartTunnels(): Promise<void> {
|
async function initializeAutoStartTunnels(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Fetch auto-start tunnels from database
|
// Fetch hosts with auto-start tunnel connections
|
||||||
const response = await axios.get('http://localhost:8081/ssh_tunnel/tunnel?allAutoStart=1', {
|
const response = await axios.get('http://localhost:8081/ssh/host', {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Internal-Request': '1'
|
'X-Internal-Request': '1'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const tunnels = response.data || [];
|
const hosts: SSHHost[] = response.data || [];
|
||||||
const autoStartTunnels = tunnels.filter((tunnel: any) => tunnel.autoStart);
|
const autoStartTunnels: TunnelConfig[] = [];
|
||||||
|
|
||||||
|
// Process each host and extract auto-start tunnel connections
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (host.enableTunnel && host.tunnelConnections) {
|
||||||
|
for (const tunnelConnection of host.tunnelConnections) {
|
||||||
|
if (tunnelConnection.autoStart) {
|
||||||
|
// Find the endpoint host
|
||||||
|
const endpointHost = hosts.find(h =>
|
||||||
|
h.name === tunnelConnection.endpointHost ||
|
||||||
|
`${h.username}@${h.ip}` === tunnelConnection.endpointHost
|
||||||
|
);
|
||||||
|
|
||||||
|
if (endpointHost) {
|
||||||
|
const tunnelConfig: TunnelConfig = {
|
||||||
|
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
|
||||||
|
hostName: host.name || `${host.username}@${host.ip}`,
|
||||||
|
sourceIP: host.ip,
|
||||||
|
sourceSSHPort: host.port,
|
||||||
|
sourceUsername: host.username,
|
||||||
|
sourcePassword: host.password,
|
||||||
|
sourceAuthMethod: host.authType,
|
||||||
|
sourceSSHKey: host.key,
|
||||||
|
sourceKeyPassword: host.keyPassword,
|
||||||
|
sourceKeyType: host.keyType,
|
||||||
|
endpointIP: endpointHost.ip,
|
||||||
|
endpointSSHPort: endpointHost.port,
|
||||||
|
endpointUsername: endpointHost.username,
|
||||||
|
endpointPassword: endpointHost.password,
|
||||||
|
endpointAuthMethod: endpointHost.authType,
|
||||||
|
endpointSSHKey: endpointHost.key,
|
||||||
|
endpointKeyPassword: endpointHost.keyPassword,
|
||||||
|
endpointKeyType: endpointHost.keyType,
|
||||||
|
sourcePort: tunnelConnection.sourcePort,
|
||||||
|
endpointPort: tunnelConnection.endpointPort,
|
||||||
|
maxRetries: tunnelConnection.maxRetries,
|
||||||
|
retryInterval: tunnelConnection.retryInterval * 1000, // Convert to milliseconds
|
||||||
|
autoStart: tunnelConnection.autoStart,
|
||||||
|
isPinned: host.pin
|
||||||
|
};
|
||||||
|
|
||||||
|
autoStartTunnels.push(tunnelConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
|
logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
|
||||||
|
|
||||||
for (const tunnel of autoStartTunnels) {
|
// Start each auto-start tunnel
|
||||||
const hostConfig: HostConfig = {
|
for (const tunnelConfig of autoStartTunnels) {
|
||||||
name: tunnel.name,
|
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
|
||||||
sourceIP: tunnel.sourceIP,
|
|
||||||
sourceSSHPort: tunnel.sourceSSHPort,
|
|
||||||
sourceUsername: tunnel.sourceUsername,
|
|
||||||
sourcePassword: tunnel.sourcePassword,
|
|
||||||
sourceAuthMethod: tunnel.sourceAuthMethod,
|
|
||||||
sourceSSHKey: tunnel.sourceSSHKey,
|
|
||||||
sourceKeyPassword: tunnel.sourceKeyPassword,
|
|
||||||
sourceKeyType: tunnel.sourceKeyType,
|
|
||||||
endpointIP: tunnel.endpointIP,
|
|
||||||
endpointSSHPort: tunnel.endpointSSHPort,
|
|
||||||
endpointUsername: tunnel.endpointUsername,
|
|
||||||
endpointPassword: tunnel.endpointPassword,
|
|
||||||
endpointAuthMethod: tunnel.endpointAuthMethod,
|
|
||||||
endpointSSHKey: tunnel.endpointSSHKey,
|
|
||||||
endpointKeyPassword: tunnel.endpointKeyPassword,
|
|
||||||
endpointKeyType: tunnel.endpointKeyType,
|
|
||||||
sourcePort: tunnel.sourcePort,
|
|
||||||
endpointPort: tunnel.endpointPort,
|
|
||||||
maxRetries: tunnel.maxRetries || 3,
|
|
||||||
retryInterval: tunnel.retryInterval || 5000,
|
|
||||||
autoStart: tunnel.autoStart,
|
|
||||||
isPinned: tunnel.isPinned || false
|
|
||||||
};
|
|
||||||
|
|
||||||
hostConfigs.set(tunnel.name, hostConfig);
|
|
||||||
|
|
||||||
// Start the tunnel
|
// Start the tunnel with a delay to avoid overwhelming the system
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
connectSSHTunnel(hostConfig, 0);
|
connectSSHTunnel(tunnelConfig, 0);
|
||||||
}, 1000); // Stagger startup to avoid overwhelming the system
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
logger.warn('Authentication required for auto-start tunnels. Skipping auto-start initialization.');
|
||||||
|
} else {
|
||||||
|
logger.error('Failed to initialize auto-start tunnels:', error.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to initialize auto-start tunnels:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1035,29 +1097,29 @@ app.get('/health', (req, res) => {
|
|||||||
|
|
||||||
// Get all tunnel configurations
|
// Get all tunnel configurations
|
||||||
app.get('/tunnels', (req, res) => {
|
app.get('/tunnels', (req, res) => {
|
||||||
const tunnels = Array.from(hostConfigs.values());
|
const tunnels = Array.from(tunnelConfigs.values());
|
||||||
res.json(tunnels);
|
res.json(tunnels);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update tunnel configuration
|
// Update tunnel configuration
|
||||||
app.put('/tunnel/:name', (req, res) => {
|
app.put('/tunnel/:name', (req, res) => {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
const hostConfig: HostConfig = req.body;
|
const tunnelConfig: TunnelConfig = req.body;
|
||||||
|
|
||||||
if (!hostConfig || !hostConfig.name) {
|
if (!tunnelConfig || !tunnelConfig.name) {
|
||||||
return res.status(400).json({ error: 'Invalid tunnel configuration' });
|
return res.status(400).json({ error: 'Invalid tunnel configuration' });
|
||||||
}
|
}
|
||||||
|
|
||||||
hostConfigs.set(name, hostConfig);
|
tunnelConfigs.set(name, tunnelConfig);
|
||||||
|
|
||||||
// If tunnel is currently connected, disconnect and reconnect with new config
|
// If tunnel is currently connected, disconnect and reconnect with new config
|
||||||
if (activeTunnels.has(name)) {
|
if (activeTunnels.has(name)) {
|
||||||
manualDisconnects.add(name);
|
manualDisconnects.add(name);
|
||||||
handleDisconnect(name, hostConfig, false);
|
handleDisconnect(name, tunnelConfig, false);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
manualDisconnects.delete(name);
|
manualDisconnects.delete(name);
|
||||||
connectSSHTunnel(hostConfig, 0);
|
connectSSHTunnel(tunnelConfig, 0);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,12 +1133,12 @@ app.delete('/tunnel/:name', (req, res) => {
|
|||||||
// Disconnect if active
|
// Disconnect if active
|
||||||
if (activeTunnels.has(name)) {
|
if (activeTunnels.has(name)) {
|
||||||
manualDisconnects.add(name);
|
manualDisconnects.add(name);
|
||||||
const hostConfig = hostConfigs.get(name) || null;
|
const tunnelConfig = tunnelConfigs.get(name) || null;
|
||||||
handleDisconnect(name, hostConfig, false);
|
handleDisconnect(name, tunnelConfig, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from configurations
|
// Remove from configurations
|
||||||
hostConfigs.delete(name);
|
tunnelConfigs.delete(name);
|
||||||
|
|
||||||
res.json({ message: 'Tunnel deleted', name });
|
res.json({ message: 'Tunnel deleted', name });
|
||||||
});
|
});
|
||||||
@@ -1084,7 +1146,6 @@ app.delete('/tunnel/:name', (req, res) => {
|
|||||||
// Start the server
|
// Start the server
|
||||||
const PORT = 8083;
|
const PORT = 8083;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
// Initialize auto-start tunnels after a short delay
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initializeAutoStartTunnels();
|
initializeAutoStartTunnels();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|||||||
Reference in New Issue
Block a user