diff --git a/src/apps/SSH/Tunnel/SSHTunnel.tsx b/src/apps/SSH/Tunnel/SSHTunnel.tsx index 325839d7..b675128e 100644 --- a/src/apps/SSH/Tunnel/SSHTunnel.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnel.tsx @@ -1,8 +1,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx"; import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx"; -import { getSSHHosts } from "@/apps/SSH/ssh-axios"; -import axios from "axios"; +import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel } from "@/apps/SSH/ssh-axios"; interface ConfigEditorProps { onSelectView: (view: string) => void; @@ -76,8 +75,7 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme // Poll backend for tunnel statuses const fetchTunnelStatuses = useCallback(async () => { try { - const res = await axios.get('http://localhost:8083/status'); - const statusData = res.data || {}; + const statusData = await getTunnelStatuses(); // Convert tunnel statuses to host statuses const newHostStatuses: Record = {}; @@ -95,37 +93,17 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme }); 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'; - } + // Just use the first tunnel's status for now - simplify + const firstTunnelStatus = hostTunnelStatuses[0]; 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), + 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 @@ -159,11 +137,7 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme return; } - // Immediately set to CONNECTING for instant UI feedback - setHostStatuses(prev => ({ - ...prev, - [hostId]: { ...prev[hostId], connectionState: "connecting" } - })); + // Let the backend handle the status updates try { // For each tunnel connection, create a tunnel configuration @@ -206,14 +180,10 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme isPinned: host.pin }; - await axios.post('http://localhost:8083/connect', tunnelConfig); + await connectTunnel(tunnelConfig); } } catch (err) { - // Reset status on error - setHostStatuses(prev => ({ - ...prev, - [hostId]: { ...prev[hostId], connectionState: "failed", statusReason: "Failed to connect" } - })); + // Let the backend handle error status updates } }; @@ -221,17 +191,13 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme const host = hosts.find(h => h.id === hostId); if (!host) return; - // Immediately set to DISCONNECTING for instant UI feedback - setHostStatuses(prev => ({ - ...prev, - [hostId]: { ...prev[hostId], connectionState: "disconnecting" } - })); + // 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 axios.post('http://localhost:8083/disconnect', { tunnelName }); + await disconnectTunnel(tunnelName); } } catch (err) { // Silent error handling diff --git a/src/apps/SSH/Tunnel/SSHTunnelObject.tsx b/src/apps/SSH/Tunnel/SSHTunnelObject.tsx index 1308a2bf..2af450aa 100644 --- a/src/apps/SSH/Tunnel/SSHTunnelObject.tsx +++ b/src/apps/SSH/Tunnel/SSHTunnelObject.tsx @@ -13,6 +13,7 @@ const CONNECTION_STATES = { FAILED: "failed", UNSTABLE: "unstable", RETRYING: "retrying", + WAITING: "waiting", DISCONNECTING: "disconnecting" }; @@ -77,6 +78,7 @@ export function SSHTunnelObject({ case "CONNECTING": case "VERIFYING": case "RETRYING": + case "WAITING": return "bg-yellow-500"; case "FAILED": return "bg-red-500"; @@ -88,27 +90,12 @@ export function SSHTunnelObject({ }; const getStatusText = (state: string) => { - const upperState = state.toUpperCase(); - switch (upperState) { - case "CONNECTED": - return "Connected"; - case "CONNECTING": - return "Connecting"; - case "VERIFYING": - return "Verifying"; - case "FAILED": - return "Failed"; - case "UNSTABLE": - return "Unstable"; - case "RETRYING": - return "Retrying"; - default: - return "Disconnected"; - } + // 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", "connecting", "verifying", "retrying"].includes(connectionState); + const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING", "WAITING", "connecting", "verifying", "retrying", "waiting"].includes(connectionState); const isDisconnecting = connectionState === "DISCONNECTING" || connectionState === "disconnecting"; return ( @@ -193,9 +180,9 @@ export function SSHTunnelObject({ )} {/* Retry Info */} - {connectionState === "RETRYING" && statusRetryCount && statusMaxRetries && ( + {(connectionState === "retrying" || connectionState === "waiting") && statusRetryCount && statusMaxRetries && (
- Retry {statusRetryCount}/{statusMaxRetries} + {connectionState === "waiting" ? "Waiting" : "Retry"} {statusRetryCount}/{statusMaxRetries} {statusNextRetryIn && ( • Next retry in {statusNextRetryIn}s )} @@ -213,7 +200,7 @@ export function SSHTunnelObject({ {isConnecting ? ( <> - Connecting... + {getStatusText(connectionState)}... ) : isConnected ? ( "Connected" diff --git a/src/apps/SSH/ssh-axios.ts b/src/apps/SSH/ssh-axios.ts index ab7833c1..ebcda5e5 100644 --- a/src/apps/SSH/ssh-axios.ts +++ b/src/apps/SSH/ssh-axios.ts @@ -44,6 +44,43 @@ interface SSHHost { updatedAt: string; } +interface TunnelConfig { + name: string; + hostName: string; + 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; + sourcePort: number; + endpointPort: number; + maxRetries: number; + retryInterval: number; + autoStart: boolean; + isPinned: boolean; +} + +interface TunnelStatus { + status: string; + reason?: string; + errorType?: string; + retryCount?: number; + maxRetries?: number; + nextRetryIn?: number; + retryExhausted?: boolean; +} + // Determine the base URL based on environment const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin; @@ -56,6 +93,13 @@ const api = axios.create({ }, }); +// Create tunnel API instance +const tunnelApi = axios.create({ + headers: { + 'Content-Type': 'application/json', + }, +}); + function getCookie(name: string): string | undefined { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); @@ -71,6 +115,14 @@ api.interceptors.request.use((config) => { return config; }); +tunnelApi.interceptors.request.use((config) => { + const token = getCookie('jwt'); // Adjust based on your token storage + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + // Get all SSH hosts export async function getSSHHosts(): Promise { try { @@ -119,22 +171,22 @@ export async function createSSHHost(hostData: SSHHostData): Promise { // Handle file upload for SSH key if (hostData.authType === 'key' && hostData.key instanceof File) { const formData = new FormData(); - + // Add the file formData.append('key', hostData.key); - + // Add all other data as JSON string const dataWithoutFile = { ...submitData }; delete dataWithoutFile.key; formData.append('data', JSON.stringify(dataWithoutFile)); - + // Submit with FormData const response = await api.post('/ssh/host', formData, { headers: { 'Content-Type': 'multipart/form-data', }, }); - + return response.data; } else { // Submit with JSON @@ -182,17 +234,17 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom if (hostData.authType === 'key' && hostData.key instanceof File) { const formData = new FormData(); formData.append('key', hostData.key); - + const dataWithoutFile = { ...submitData }; delete dataWithoutFile.key; formData.append('data', JSON.stringify(dataWithoutFile)); - + const response = await api.put(`/ssh/host/${hostId}`, formData, { headers: { 'Content-Type': 'multipart/form-data', }, }); - + return response.data; } else { const response = await api.put(`/ssh/host/${hostId}`, submitData); @@ -226,4 +278,45 @@ export async function getSSHHostById(hostId: number): Promise { } } -export { api }; \ No newline at end of file +// Tunnel-related functions + +// Get tunnel statuses +export async function getTunnelStatuses(): Promise> { + try { + // Determine the tunnel API URL based on environment + const tunnelUrl = isLocalhost ? 'http://localhost:8083/status' : `${baseURL}/ssh_tunnel/status`; + const response = await tunnelApi.get(tunnelUrl); + return response.data || {}; + } catch (error) { + console.error('Error fetching tunnel statuses:', error); + throw error; + } +} + +// Connect tunnel +export async function connectTunnel(tunnelConfig: TunnelConfig): Promise { + try { + // Determine the tunnel API URL based on environment + const tunnelUrl = isLocalhost ? 'http://localhost:8083/connect' : `${baseURL}/ssh_tunnel/connect`; + const response = await tunnelApi.post(tunnelUrl, tunnelConfig); + return response.data; + } catch (error) { + console.error('Error connecting tunnel:', error); + throw error; + } +} + +// Disconnect tunnel +export async function disconnectTunnel(tunnelName: string): Promise { + try { + // Determine the tunnel API URL based on environment + const tunnelUrl = isLocalhost ? 'http://localhost:8083/disconnect' : `${baseURL}/ssh_tunnel/disconnect`; + const response = await tunnelApi.post(tunnelUrl, { tunnelName }); + return response.data; + } catch (error) { + console.error('Error disconnecting tunnel:', error); + throw error; + } +} + +export { api }; \ No newline at end of file diff --git a/src/backend/config_editor/old_database.js b/src/backend/config_editor/old_database.js deleted file mode 100644 index b52e7774..00000000 --- a/src/backend/config_editor/old_database.js +++ /dev/null @@ -1,401 +0,0 @@ -const express = require('express'); -const http = require('http'); -const Database = require('better-sqlite3'); -const bcrypt = require('bcrypt'); -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); -const cors = require("cors"); -const jwt = require('jsonwebtoken'); -require('dotenv').config(); - -const app = express(); -const PORT = 8081; - -app.use(cors({ - origin: true, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] -})); -app.use(express.json()); - -const getReadableTimestamp = () => { - return new Intl.DateTimeFormat('en-US', { - dateStyle: 'medium', - timeStyle: 'medium', - timeZone: 'UTC', - }).format(new Date()); -}; - -const logger = { - info: (...args) => console.log(`💾 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args), - error: (...args) => console.error(`💾 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args), - warn: (...args) => console.warn(`💾 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args), - debug: (...args) => console.debug(`💾 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args) -}; - -const SALT = process.env.SALT || 'default_salt'; -const JWT_SECRET = SALT + '_jwt_secret'; -const DB_PATH = path.join(__dirname, 'data', 'users.db'); - -const dataDir = path.join(__dirname, 'data'); -if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); -} - -const db = new Database(DB_PATH); -db.pragma('journal_mode = WAL'); -db.prepare(`CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - created_at TEXT NOT NULL, - is_admin BOOLEAN DEFAULT 0, - theme TEXT DEFAULT 'vscode' -)`).run(); - -db.prepare(`CREATE TABLE IF NOT EXISTS settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - signup_enabled BOOLEAN DEFAULT 1 -)`).run(); - -const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; -if (settingsCount === 0) { - db.prepare('INSERT INTO settings (signup_enabled) VALUES (1)').run(); -} - -db.prepare(`CREATE TABLE IF NOT EXISTS user_recent_files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - file_path TEXT NOT NULL, - file_name TEXT NOT NULL, - last_opened TEXT NOT NULL, - server_name TEXT, - server_ip TEXT, - server_port INTEGER, - server_user TEXT, - server_default_path TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -)`).run(); - -db.prepare(`CREATE TABLE IF NOT EXISTS user_starred_files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - file_path TEXT NOT NULL, - file_name TEXT NOT NULL, - last_opened TEXT NOT NULL, - server_name TEXT, - server_ip TEXT, - server_port INTEGER, - server_user TEXT, - server_default_path TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -)`).run(); - -db.prepare(`CREATE TABLE IF NOT EXISTS user_folder_shortcuts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - folder_path TEXT NOT NULL, - folder_name TEXT NOT NULL, - server_name TEXT, - server_ip TEXT, - server_port INTEGER, - server_user TEXT, - server_default_path TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -)`).run(); - -db.prepare(`CREATE TABLE IF NOT EXISTS user_open_tabs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - tab_id TEXT NOT NULL, - file_name TEXT NOT NULL, - file_path TEXT NOT NULL, - content TEXT, - saved_content TEXT, - is_dirty BOOLEAN DEFAULT 0, - server_name TEXT, - server_ip TEXT, - server_port INTEGER, - server_user TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -)`).run(); - -db.prepare(`CREATE TABLE IF NOT EXISTS user_current_path ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL UNIQUE, - current_path TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -)`).run(); - -db.prepare(`CREATE TABLE IF NOT EXISTS user_ssh_servers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - server_name TEXT NOT NULL, - server_ip TEXT NOT NULL, - server_port INTEGER DEFAULT 22, - username TEXT NOT NULL, - password TEXT, - ssh_key TEXT, - default_path TEXT DEFAULT '/', - created_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -)`).run(); - -function getKeyAndIV() { - const key = crypto.createHash('sha256').update(SALT).digest(); - const iv = Buffer.alloc(16, 0); - return { key, iv }; -} - -function encrypt(text) { - const { key, iv } = getKeyAndIV(); - const cipher = crypto.createCipheriv('aes-256-ctr', key, iv); - let crypted = cipher.update(text, 'utf8', 'hex'); - crypted += cipher.final('hex'); - return crypted; -} - -function decrypt(text) { - const { key, iv } = getKeyAndIV(); - const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv); - let dec = decipher.update(text, 'hex', 'utf8'); - dec += decipher.final('utf8'); - return dec; -} - -function generateToken(user) { - return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' }); -} - -function authMiddleware(req, res, next) { - const authHeader = req.headers['authorization']; - if (!authHeader) return res.status(401).json({ error: 'No token provided' }); - const token = authHeader.split(' ')[1]; - if (!token) return res.status(401).json({ error: 'Invalid token format' }); - try { - const decoded = jwt.verify(token, JWT_SECRET); - req.user = decoded; - next(); - } catch (err) { - return res.status(401).json({ error: 'Invalid or expired token' }); - } -} - -app.post('/register', async (req, res) => { - const { username, password } = req.body; - if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); - - const settings = db.prepare('SELECT signup_enabled FROM settings WHERE id = 1').get(); - if (!settings.signup_enabled) { - return res.status(403).json({ error: 'Signups are currently disabled' }); - } - - try { - const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; - const isFirstUser = userCount === 0; - - const hash = await bcrypt.hash(password + SALT, 10); - const stmt = db.prepare('INSERT INTO users (username, password, created_at, is_admin) VALUES (?, ?, ?, ?)'); - stmt.run(username, encrypt(hash), new Date().toISOString(), isFirstUser ? 1 : 0); - - const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); - const token = generateToken(user); - return res.json({ - token, - user: { - id: user.id, - username: user.username, - isAdmin: user.is_admin === 1 - }, - isFirstUser - }); - } catch (err) { - if (err.code === 'SQLITE_CONSTRAINT') { - return res.status(409).json({ error: 'Username already exists' }); - } - logger.error('Registration error:', err); - return res.status(500).json({ error: 'Registration failed' }); - } -}); - -app.post('/login', async (req, res) => { - const { username, password } = req.body; - if (!username || !password) return res.status(401).json({ error: 'Username and password required' }); - try { - const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); - if (!user) return res.status(401).json({ error: 'Invalid credentials' }); - const hash = decrypt(user.password); - const valid = await bcrypt.compare(password + SALT, hash); - if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); - const token = generateToken(user); - return res.json({ - token, - user: { - id: user.id, - username: user.username, - isAdmin: user.is_admin === 1 - } - }); - } catch (err) { - logger.error('Login error:', err); - return res.status(500).json({ error: 'Login failed' }); - } -}); - -app.get('/profile', authMiddleware, (req, res) => { - const user = db.prepare('SELECT id, username, created_at, is_admin FROM users WHERE id = ?').get(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); - return res.json({ - user: { - id: user.id, - username: user.username, - created_at: user.created_at, - isAdmin: user.is_admin === 1 - } - }); -}); - -app.get('/check-first-user', (req, res) => { - const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; - return res.json({ isFirstUser: userCount === 0 }); -}); - -app.get('/admin/settings', authMiddleware, (req, res) => { - const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(req.user.id); - if (!user || !user.is_admin) return res.status(403).json({ error: 'Admin access required' }); - - const settings = db.prepare('SELECT signup_enabled FROM settings WHERE id = 1').get(); - return res.json({ settings }); -}); - -app.post('/admin/settings', authMiddleware, (req, res) => { - const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(req.user.id); - if (!user || !user.is_admin) return res.status(403).json({ error: 'Admin access required' }); - - const { signup_enabled } = req.body; - if (typeof signup_enabled !== 'boolean') { - return res.status(400).json({ error: 'Invalid signup_enabled value' }); - } - - db.prepare('UPDATE settings SET signup_enabled = ? WHERE id = 1').run(signup_enabled ? 1 : 0); - return res.json({ message: 'Settings updated successfully' }); -}); - -app.use('/file', authMiddleware); -app.use('/files', authMiddleware); - -app.post('/user/data', authMiddleware, (req, res) => { - const { recentFiles, starredFiles, folderShortcuts, openTabs, currentPath, sshServers, theme } = req.body; - const userId = req.user.id; - - try { - db.prepare('BEGIN').run(); - - if (recentFiles) { - db.prepare('DELETE FROM user_recent_files WHERE user_id = ?').run(userId); - const stmt = db.prepare('INSERT INTO user_recent_files (user_id, file_path, file_name, last_opened, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); - recentFiles.forEach(file => { - stmt.run(userId, file.path, file.name, file.lastOpened, file.serverName, file.serverIp, file.serverPort, file.serverUser, file.serverDefaultPath); - }); - } - - if (starredFiles) { - db.prepare('DELETE FROM user_starred_files WHERE user_id = ?').run(userId); - const stmt = db.prepare('INSERT INTO user_starred_files (user_id, file_path, file_name, last_opened, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); - starredFiles.forEach(file => { - stmt.run(userId, file.path, file.name, file.lastOpened, file.serverName, file.serverIp, file.serverPort, file.serverUser, file.serverDefaultPath); - }); - } - - if (folderShortcuts) { - db.prepare('DELETE FROM user_folder_shortcuts WHERE user_id = ?').run(userId); - const stmt = db.prepare('INSERT INTO user_folder_shortcuts (user_id, folder_path, folder_name, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); - folderShortcuts.forEach(folder => { - stmt.run(userId, folder.path, folder.name, folder.serverName, folder.serverIp, folder.serverPort, folder.serverUser, folder.serverDefaultPath); - }); - } - - if (openTabs) { - db.prepare('DELETE FROM user_open_tabs WHERE user_id = ?').run(userId); - const stmt = db.prepare('INSERT INTO user_open_tabs (user_id, tab_id, file_name, file_path, content, saved_content, is_dirty, server_name, server_ip, server_port, server_user) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); - openTabs.forEach(tab => { - stmt.run(userId, tab.id, tab.name, tab.path, tab.content || '', tab.savedContent || '', tab.isDirty ? 1 : 0, tab.serverName, tab.serverIp, tab.serverPort, tab.serverUser); - }); - } - - if (currentPath) { - db.prepare('INSERT OR REPLACE INTO user_current_path (user_id, current_path) VALUES (?, ?)').run(userId, currentPath); - } - - if (sshServers) { - db.prepare('DELETE FROM user_ssh_servers WHERE user_id = ?').run(userId); - const stmt = db.prepare('INSERT INTO user_ssh_servers (user_id, server_name, server_ip, server_port, username, password, ssh_key, default_path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); - sshServers.forEach(server => { - stmt.run(userId, server.name, server.ip, server.port || 22, server.user, server.password ? encrypt(server.password) : null, server.sshKey ? encrypt(server.sshKey) : null, server.defaultPath || '/', server.createdAt || new Date().toISOString()); - }); - } - - if (theme) { - db.prepare('UPDATE users SET theme = ? WHERE id = ?').run(theme, userId); - } - - db.prepare('COMMIT').run(); - res.json({ message: 'User data saved successfully' }); - } catch (err) { - db.prepare('ROLLBACK').run(); - logger.error('Error saving user data:', err); - res.status(500).json({ error: 'Failed to save user data' }); - } -}); - -app.get('/user/data', authMiddleware, (req, res) => { - const userId = req.user.id; - - try { - const recentFiles = db.prepare('SELECT file_path as path, file_name as name, last_opened as lastOpened, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_recent_files WHERE user_id = ?').all(userId); - - const starredFiles = db.prepare('SELECT file_path as path, file_name as name, last_opened as lastOpened, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_starred_files WHERE user_id = ?').all(userId); - - const folderShortcuts = db.prepare('SELECT folder_path as path, folder_name as name, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_folder_shortcuts WHERE user_id = ?').all(userId); - - const openTabs = db.prepare('SELECT tab_id as id, file_name as name, file_path as path, content, saved_content as savedContent, is_dirty as isDirty, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser FROM user_open_tabs WHERE user_id = ?').all(userId); - - const currentPath = db.prepare('SELECT current_path FROM user_current_path WHERE user_id = ?').get(userId); - - const sshServers = db.prepare('SELECT server_name as name, server_ip as ip, server_port as port, username as user, password, ssh_key as sshKey, default_path as defaultPath, created_at as createdAt FROM user_ssh_servers WHERE user_id = ?').all(userId); - - const decryptedServers = sshServers.map(server => ({ - ...server, - password: server.password ? decrypt(server.password) : null, - sshKey: server.sshKey ? decrypt(server.sshKey) : null - })); - - const userTheme = db.prepare('SELECT theme FROM users WHERE id = ?').get(userId)?.theme || 'vscode'; - - const data = { - recentFiles, - starredFiles, - folderShortcuts, - openTabs, - currentPath: currentPath?.current_path || '/', - sshServers: decryptedServers, - theme: userTheme - }; - res.json(data); - } catch (err) { - logger.error('Error loading user data:', err); - res.status(500).json({ error: 'Failed to load user data' }); - } -}); - -try { - db.prepare('ALTER TABLE users ADD COLUMN theme TEXT DEFAULT "vscode"').run(); -} catch (e) { - if (!e.message.includes('duplicate column')) throw e; -} - -app.listen(PORT, () => { - logger.info(`Database API listening at http://localhost:${PORT}`); -}); \ No newline at end of file diff --git a/src/backend/config_editor/old_file_manager.js b/src/backend/config_editor/old_file_manager.js deleted file mode 100644 index 9c76190c..00000000 --- a/src/backend/config_editor/old_file_manager.js +++ /dev/null @@ -1,141 +0,0 @@ -const express = require('express'); -const fs = require('fs'); -const path = require('path'); -const cors = require('cors'); - -const app = express(); -const PORT = 8082; - -app.use(cors({ - origin: true, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] -})); -app.use(express.json()); - -const getReadableTimestamp = () => { - return new Intl.DateTimeFormat('en-US', { - dateStyle: 'medium', - timeStyle: 'medium', - timeZone: 'UTC', - }).format(new Date()); -}; - -const logger = { - info: (...args) => console.log(`📁 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args), - error: (...args) => console.error(`📁 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args), - warn: (...args) => console.warn(`📁 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args), - debug: (...args) => console.debug(`📁 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args) -}; - -function normalizeFilePath(inputPath) { - if (!inputPath || typeof inputPath !== 'string') { - throw new Error('Invalid path'); - } - - let normalizedPath = inputPath.replace(/\\/g, '/'); - - const windowsAbsPath = /^[a-zA-Z]:\//; - if (windowsAbsPath.test(normalizedPath)) { - return path.resolve(normalizedPath); - } - - if (normalizedPath.startsWith('/')) { - return path.resolve(normalizedPath); - } - - return path.resolve(process.cwd(), normalizedPath); -} - -function isDirectory(path) { - try { - return fs.statSync(path).isDirectory(); - } catch (e) { - return false; - } -} - -app.get('/files', (req, res) => { - try { - const folderParam = req.query.folder || ''; - const folderPath = normalizeFilePath(folderParam); - - if (!fs.existsSync(folderPath) || !isDirectory(folderPath)) { - return res.status(404).json({ error: 'Directory not found' }); - } - - fs.readdir(folderPath, { withFileTypes: true }, (err, files) => { - if (err) { - logger.error('Error reading directory:', err); - return res.status(500).json({ error: err.message }); - } - - const result = files.map(f => ({ - name: f.name, - type: f.isDirectory() ? 'directory' : 'file', - })); - - res.json(result); - }); - } catch (err) { - logger.error('Error in /files endpoint:', err); - res.status(400).json({ error: err.message }); - } -}); - -app.get('/file', (req, res) => { - try { - const folderParam = req.query.folder || ''; - const fileName = req.query.name; - if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' }); - - const folderPath = normalizeFilePath(folderParam); - const filePath = path.join(folderPath, fileName); - - if (!fs.existsSync(filePath)) { - logger.error(`File not found: ${filePath}`); - return res.status(404).json({ error: 'File not found' }); - } - - if (isDirectory(filePath)) { - logger.error(`Path is a directory: ${filePath}`); - return res.status(400).json({ error: 'Path is a directory' }); - } - - const content = fs.readFileSync(filePath, 'utf8'); - res.setHeader('Content-Type', 'text/plain'); - res.send(content); - } catch (err) { - logger.error('Error in /file GET endpoint:', err); - res.status(500).json({ error: err.message }); - } -}); - -app.post('/file', (req, res) => { - try { - const folderParam = req.query.folder || ''; - const fileName = req.query.name; - const content = req.body.content; - - if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' }); - if (content === undefined) return res.status(400).json({ error: 'Missing "content" in request body' }); - - const folderPath = normalizeFilePath(folderParam); - const filePath = path.join(folderPath, fileName); - - if (!fs.existsSync(folderPath)) { - fs.mkdirSync(folderPath, { recursive: true }); - } - - fs.writeFileSync(filePath, content, 'utf8'); - res.json({ message: 'File written successfully' }); - } catch (err) { - logger.error('Error in /file POST endpoint:', err); - res.status(500).json({ error: err.message }); - } -}); - -app.listen(PORT, () => { - logger.info(`File manager API listening at http://localhost:${PORT}`); -}); \ No newline at end of file diff --git a/src/backend/config_editor/old_file_viewer.jsx b/src/backend/config_editor/old_file_viewer.jsx deleted file mode 100644 index f0d9062f..00000000 --- a/src/backend/config_editor/old_file_viewer.jsx +++ /dev/null @@ -1,1068 +0,0 @@ -import React, {useState, useEffect, useRef} from 'react'; -import {Button, Divider, Text, TextInput, Group, ScrollArea, Paper, Stack, ActionIcon, Modal, Loader} from "@mantine/core"; -import { ArrowUp, Folder, File, FolderOpen, Star, Server, Plus, Monitor, Edit, Trash2 } from 'lucide-react'; -import { SSHServerModal } from './SSHServerModal.jsx'; - -const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; -const isIPAddress = /^\d+\.\d+\.\d+\.\d+$/.test(window.location.hostname); - -const API_BASE = isLocalhost - ? `${window.location.protocol}//${window.location.hostname}:8082` - : isIPAddress - ? `${window.location.protocol}//${window.location.hostname}:${window.location.port}/fileviewer` - : `${window.location.protocol}//${window.location.hostname}:${window.location.port}/fileviewer`; - -const SSH_API_BASE = isLocalhost - ? `${window.location.protocol}//${window.location.hostname}:8083` - : isIPAddress - ? `${window.location.protocol}//${window.location.hostname}:${window.location.port}/ssh` - : `${window.location.protocol}//${window.location.hostname}:${window.location.port}/ssh`; - -const DB_API_BASE = isLocalhost - ? `${window.location.protocol}//${window.location.hostname}:8081` - : isIPAddress - ? `${window.location.protocol}//${window.location.hostname}:${window.location.port}/database` - : `${window.location.protocol}//${window.location.hostname}:${window.location.port}/database`; - -const CONFIG_FILE_EXTENSIONS = [ - '.json', '.yaml', '.yml', '.xml', '.ini', '.conf', '.config', - '.toml', '.env', '.properties', '.cfg', '.txt', '.md', '.log' -]; - -const LOCAL_SERVER = { - name: 'Local Container', - ip: 'local', - port: null, - user: null, - defaultPath: '/', - isLocal: true -}; - -export function FileViewer(props) { - const { onFileSelect, starredFiles, setStarredFiles, folder, setFolder, tabs, sshServers, setSSHServers, onSSHConnect, setCurrentServer, setTabState, setConnectingToServer, connectingToServer } = props; - const [files, setFiles] = useState([]); - const [message, setMessage] = useState(''); - const [configFiles, setConfigFiles] = useState([]); - const [currentServerState, setCurrentServerState] = useState(null); - const [isSSHMode, setIsSSHMode] = useState(false); - const [showSSHModal, setShowSSHModal] = useState(false); - const [editingServer, setEditingServer] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [localDefaultPath, setLocalDefaultPath] = useState('/'); - const [localContainerName, setLocalContainerName] = useState(LOCAL_SERVER.name); - const pathInputRef = useRef(null); - - const getLocalServer = () => ({ - ...LOCAL_SERVER, - name: localContainerName, - defaultPath: localDefaultPath - }); - - const handleBack = async () => { - const defaultPath = currentServerState?.defaultPath || localDefaultPath; - const normalize = p => (p || '').replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').toLowerCase(); - const normalizedFolder = normalize(folder); - let atRoot = false; - if (currentServerState?.isLocal && navigator.platform.includes('Win')) { - const driveRoot = (folder.split('/')[0] || 'C') + '/'; - atRoot = normalize(folder) === normalize(driveRoot); - } else { - atRoot = normalizedFolder === '' || normalizedFolder === '/'; - } - if (atRoot) { - return; - } - if (folder && folder !== '/') { - const normalizedPath = folder.replace(/\\/g, '/'); - const parts = normalizedPath.split('/').filter(Boolean); - if (parts.length > 0) { - parts.pop(); - let newPath; - if (currentServerState?.isLocal && navigator.platform.includes('Win')) { - let drive = parts[0] || 'C'; - if (drive.endsWith(':')) drive = drive.slice(0, -1); - newPath = parts.length > 0 ? drive + ':/' : (folder.split('/')[0] + '/'); - } else { - newPath = parts.length > 0 ? '/' + parts.join('/') : '/'; - } - setFolder(newPath); - } - } - }; - - const handleAddSSHServer = async (serverConfig) => { - try { - const connectResponse = await fetch(`${SSH_API_BASE}/sshConnect`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - }, - body: JSON.stringify({ - ip: serverConfig.ip, - port: serverConfig.port, - user: serverConfig.user, - password: serverConfig.password, - sshKey: serverConfig.sshKey - }) - }); - - if (!connectResponse.ok) { - const errorData = await connectResponse.json(); - throw new Error(errorData.message || 'Failed to connect to server'); - } - - await fetch(`${SSH_API_BASE}/sshDisconnect`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - } - }); - - setSSHServers(prev => [...prev, serverConfig]); - } catch (error) { - throw error; - } - }; - - const handleEditSSHServer = async (oldServer, newServerConfig) => { - try { - if (oldServer.isLocal) { - setLocalDefaultPath(newServerConfig.defaultPath || '/'); - setLocalContainerName(newServerConfig.name || 'Local Container'); - localStorage.setItem('localDefaultPath', newServerConfig.defaultPath || '/'); - localStorage.setItem('localContainerName', newServerConfig.name || 'Local Container'); - return; - } - - const connectResponse = await fetch(`${SSH_API_BASE}/sshConnect`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - }, - body: JSON.stringify({ - ip: newServerConfig.ip, - port: newServerConfig.port, - user: newServerConfig.user, - password: newServerConfig.password, - sshKey: newServerConfig.sshKey - }) - }); - - if (!connectResponse.ok) { - const errorData = await connectResponse.json(); - throw new Error(errorData.message || 'Failed to connect to server'); - } - - await fetch(`${SSH_API_BASE}/sshDisconnect`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - } - }); - - setSSHServers(prev => prev.map(server => - server.name === oldServer.name ? newServerConfig : server - )); - } catch (error) { - throw error; - } - }; - - const handleDeleteSSHServer = async (server) => { - try { - const updatedServers = sshServers.filter(s => s.name !== server.name); - - const response = await fetch(`${DB_API_BASE}/user/data`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - }, - body: JSON.stringify({ - sshServers: updatedServers - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || 'Failed to delete server'); - } - - setSSHServers(updatedServers); - setMessage('Server deleted successfully'); - } catch (error) { - setMessage(`Error deleting server: ${error.message}`); - } - }; - - const handleServerClick = async (server) => { - try { - setIsLoading(true); - setMessage('Connecting to server...'); - setConnectingToServer(server); - setCurrentServerState(server); - setIsSSHMode(true); - setFolder(server.defaultPath || '/'); - - let connected = false; - if (onSSHConnect) { - connected = await onSSHConnect(server); - } else { - const connectResponse = await fetch(`${SSH_API_BASE}/sshConnect`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - }, - body: JSON.stringify({ - ip: server.ip, - port: server.port, - user: server.user, - password: server.password, - sshKey: server.sshKey - }) - }); - - if (!connectResponse.ok) { - const errorData = await connectResponse.json(); - setMessage(`Failed to connect: ${errorData.message}`); - setIsSSHMode(false); - setCurrentServerState(null); - setConnectingToServer(null); - setIsLoading(false); - return; - } - connected = true; - } - - if (!connected) { - setMessage('Failed to connect to server'); - setIsSSHMode(false); - setCurrentServerState(null); - setConnectingToServer(null); - setIsLoading(false); - return; - } - - if (setCurrentServer) { - setCurrentServer(server); - } - setConnectingToServer(null); - - await loadSSHFiles(server.defaultPath || '/'); - setIsLoading(false); - } catch (error) { - setMessage(`Error connecting to server: ${error.message}`); - setIsSSHMode(false); - setCurrentServerState(null); - setConnectingToServer(null); - setIsLoading(false); - } - }; - - const handleLocalContainerClick = () => { - setIsSSHMode(false); - const localServer = getLocalServer(); - setCurrentServerState(localServer); - if (setCurrentServer) { - setCurrentServer(localServer); - } - const defaultPath = localDefaultPath; - setFolder(defaultPath); - - fetch(`${API_BASE}/files?folder=${encodeURIComponent(defaultPath)}`, { - headers: localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {} - }) - .then(res => res.json()) - .then(data => { - if (data.error) { - setMessage(data.error); - setFiles([]); - } else { - setFiles(data); - setMessage(''); - } - }) - .catch(e => { - setMessage('Error loading folder: ' + e.message); - setFiles([]); - }); - }; - - const loadSSHFiles = async (path) => { - try { - setIsLoading(true); - const response = await fetch(`${SSH_API_BASE}/listFiles?path=${encodeURIComponent(path)}`, { - headers: localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {} - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || 'Failed to list files'); - } - - const data = await response.json(); - if (data.status === 'success') { - const filteredFiles = data.files.filter(file => file.name !== '.' && file.name !== '..'); - setFiles(filteredFiles); - setMessage(''); - } else { - throw new Error(data.message || 'Failed to list files'); - } - setIsLoading(false); - } catch (error) { - setMessage(`Error loading files: ${error.message}`); - setFiles([]); - setIsLoading(false); - } - }; - - const scanFolderForConfigs = async (folderPath) => { - try { - const response = await fetch(`${API_BASE}/files?folder=${encodeURIComponent(folderPath)}`); - const data = await response.json(); - - if (data.error) { - setMessage(data.error); - return; - } - - const configs = []; - for (const item of data) { - if (item.type === 'directory') { - const subConfigs = await scanFolderForConfigs(`${folderPath}/${item.name}`); - configs.push(...subConfigs); - } else if (CONFIG_FILE_EXTENSIONS.some(ext => item.name.toLowerCase().endsWith(ext))) { - configs.push({ - name: item.name, - path: `${folderPath}/${item.name}`, - type: 'file' - }); - } - } - return configs; - } catch (error) { - return []; - } - }; - - useEffect(() => { - setIsLoading(true); - setMessage(''); - if (isSSHMode && currentServerState) { - loadSSHFiles(folder); - } else if (!isSSHMode && folder) { - fetch(`${API_BASE}/files?folder=${encodeURIComponent(folder)}`, { - headers: localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {} - }) - .then(res => res.json()) - .then(data => { - if (data.error) { - setMessage(data.error); - setFiles([]); - } else { - setFiles(data); - setMessage(''); - } - setIsLoading(false); - }) - .catch(e => { - setMessage('Error loading folder: ' + e.message); - setFiles([]); - setIsLoading(false); - }); - } else { - setFiles([]); - setIsLoading(false); - } - }, [folder, isSSHMode, currentServerState]); - - useEffect(() => { - const handleSaveFile = async (event) => { - const {content, folder, filename} = event.detail; - - if (isSSHMode && currentServerState) { - try { - const filePath = `${folder}/${filename}`.replace(/\\/g, '/').replace(/\/+/g, '/'); - const response = await fetch(`${SSH_API_BASE}/writeFile`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - }, - body: JSON.stringify({ - path: filePath, - content: content - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - setMessage(errorData.message || 'Failed to save file'); - } else { - setMessage('File saved successfully'); - } - } catch (error) { - setMessage('Error saving file: ' + error.message); - } - } else { - fetch(`${API_BASE}/file?folder=${encodeURIComponent(folder)}&name=${encodeURIComponent(filename)}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - }, - body: JSON.stringify({content}), - }) - .then(res => res.json()) - .then(data => { - if (data.error) { - setMessage(data.error); - } else { - setMessage(data.message || 'File saved successfully'); - } - }) - .catch(e => setMessage('Error writing file: ' + e.message)); - } - }; - - window.addEventListener('saveFile', handleSaveFile); - return () => window.removeEventListener('saveFile', handleSaveFile); - }, [isSSHMode, currentServerState]); - - const handleFileClick = async (name, type) => { - if (type === 'file') { - if (isSSHMode && currentServerState) { - const filePath = `${folder}/${name}`.replace(/\\/g, '/').replace(/\/+/g, '/'); - onFileSelect(name, folder, currentServerState, filePath); - } else { - onFileSelect(name, folder); - } - } else { - const newPath = folder.endsWith('/') ? folder + name : folder + '/' + name; - setFolder(newPath); - } - setMessage(''); - }; - - const connectToSSHServer = async (server) => { - try { - setIsLoading(true); - setMessage('Connecting to server...'); - setCurrentServerState(server); - setIsSSHMode(true); - - let connected = false; - if (onSSHConnect) { - connected = await onSSHConnect(server); - } else { - const connectResponse = await fetch(`${SSH_API_BASE}/sshConnect`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - }, - body: JSON.stringify({ - ip: server.ip, - port: server.port, - user: server.user, - password: server.password, - sshKey: server.sshKey - }) - }); - - if (!connectResponse.ok) { - const errorData = await connectResponse.json(); - throw new Error(errorData.message || 'Failed to connect to server'); - } - connected = true; - } - - if (!connected) { - throw new Error('Failed to connect to server'); - } - - setIsLoading(false); - return true; - } catch (error) { - setMessage(`Error connecting to server: ${error.message}`); - setIsSSHMode(false); - setCurrentServerState(null); - setIsLoading(false); - return false; - } - }; - - const handleStarFile = (file) => { - const filePath = file.path || `${folder}/${file.name}`.replace(/\\/g, '/').replace(/\/+/g, '/'); - const fileInfo = { - name: file.name, - path: filePath, - lastOpened: new Date().toISOString(), - server: currentServerState ? { - name: currentServerState.name, - ip: currentServerState.ip, - port: currentServerState.port, - user: currentServerState.user - } : null - }; - const isStarred = starredFiles.some(f => f.path === filePath); - if (isStarred) { - setStarredFiles(starredFiles.filter(f => f.path !== filePath)); - } else { - setStarredFiles([...starredFiles, fileInfo]); - } - }; - - useEffect(() => { - if (pathInputRef.current) { - const input = pathInputRef.current; - input.scrollLeft = input.scrollWidth; - } - }, [folder]); - - useEffect(() => { - const savedPath = localStorage.getItem('localDefaultPath'); - const savedName = localStorage.getItem('localContainerName'); - if (savedPath) { - setLocalDefaultPath(savedPath); - } else { - setLocalDefaultPath('/'); - } - if (savedName) { - setLocalContainerName(savedName); - } - }, []); - - if (!isSSHMode && !currentServerState && (!folder || folder === '/')) { - return ( - - - - Available Servers - - - - {/* Local Container */} - e.currentTarget.style.backgroundColor = '#4A5568'} - onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} - onClick={handleLocalContainerClick} - > - -
- - {localContainerName} - - - {localDefaultPath} - -
-
- { - e.stopPropagation(); - setEditingServer(getLocalServer()); - setShowSSHModal(true); - }} - > - - -
-
- - {/* SSH Servers */} - {sshServers - .sort((a, b) => { - const aStarred = starredFiles.some(f => f.path === `ssh://${a.name}`); - const bStarred = starredFiles.some(f => f.path === `ssh://${b.name}`); - if (aStarred && !bStarred) return -1; - if (!aStarred && bStarred) return 1; - return a.name.localeCompare(b.name); - }) - .map((server, index) => { - const isStarred = starredFiles.some(f => f.path === `ssh://${server.name}`); - return ( - e.currentTarget.style.backgroundColor = '#4A5568'} - onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} - onClick={() => handleServerClick(server)} - > - -
- - {server.name} - - - {server.user}@{server.ip}:{server.port} - -
-
- { - e.stopPropagation(); - setEditingServer(server); - setShowSSHModal(true); - }} - > - - - { - e.stopPropagation(); - handleDeleteSSHServer(server); - }} - > - - - { - e.stopPropagation(); - const serverPath = `ssh://${server.name}`; - const serverInfo = { - name: server.name, - path: serverPath, - lastOpened: new Date().toISOString(), - server: server - }; - const isCurrentlyStarred = starredFiles.some(f => f.path === serverPath); - if (isCurrentlyStarred) { - setStarredFiles(starredFiles.filter(f => f.path !== serverPath)); - } else { - setStarredFiles([...starredFiles, serverInfo]); - } - }} - > - {isStarred ? ( - - ) : ( - - )} - -
-
- ); - })} - - {sshServers.length === 0 && ( - - No SSH servers added yet - - )} -
-
- - - setShowSSHModal(true)} - style={{ - cursor: 'pointer', - backgroundColor: '#36414C', - border: '1px solid #4A5568', - userSelect: 'none', - transition: 'background 0.2s', - }} - onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'} - onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} - > - - - Add SSH Server - - -
-
- - { - setShowSSHModal(false); - setEditingServer(null); - }} - onAddServer={handleAddSSHServer} - onEditServer={handleEditSSHServer} - editingServer={editingServer} - /> -
- ); - } - - return ( - - - - - {isSSHMode ? `SSH Path` : 'Local Machine Path'} - - { - setFolder(e.target.value); - }} - placeholder={isSSHMode ? "Enter SSH path e.g. /home/user" : "Enter folder path e.g. C:\\Users\\Luke or /Users/luke"} - styles={{ - input: { - backgroundColor: '#36414C', - borderColor: '#4A5568', - color: 'white', - '&::placeholder': { - color: '#A0AEC0' - } - } - }} - /> - - - - - - - {isSSHMode ? 'SSH File Manager' : 'File Manager'} - - - - - {(isLoading || connectingToServer) && ( - - - - {connectingToServer ? 'Loading...' : 'Loading...'} - - - )} - {!isLoading && !connectingToServer && files.length === 0 && !isSSHMode && ( - - No files found - - )} - {!isLoading && !connectingToServer && files.length === 0 && isSSHMode && ( - - No files found in SSH directory - - )} - {!isLoading && !connectingToServer && files.map(({name, type}) => { - const normalizedPath = `${folder}/${name}`.replace(/\\/g, '/').replace(/\/+/g, '/'); - const isOpen = type === 'file' && tabs.some(tab => tab.path === normalizedPath); - const isStarred = starredFiles.some(f => f.path === normalizedPath); - return ( - e.currentTarget.style.backgroundColor = isOpen ? '#23272f' : '#4A5568'} - onMouseOut={e => e.currentTarget.style.backgroundColor = isOpen ? '#23272f' : '#36414C'} - onClick={() => !isOpen && handleFileClick(name, type)} - > -
- {type === 'directory' ? ( - - ) : ( - - )} - - {name} - -
- {type === 'file' && ( -
- { - e.stopPropagation(); - handleStarFile({ name, type, path: normalizedPath }); - }} - > - {isStarred ? ( - - ) : ( - - )} - -
- )} -
- ); - })} -
-
- - {folder && ( - <> - - - e.currentTarget.style.backgroundColor = '#4A5568'} - onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} - > - - - Back - - - { - setIsSSHMode(false); - setCurrentServerState(null); - setFolder('/'); - if (setCurrentServer) { - setCurrentServer(null); - } - if (setTabState) { - setTabState({ tabs: [], activeTab: 'home' }); - } - if (!currentServerState?.isLocal) { - fetch(`${SSH_API_BASE}/sshDisconnect`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) - } - }); - } - }} - style={{ - cursor: 'pointer', - backgroundColor: '#36414C', - border: '1px solid #4A5568', - userSelect: 'none', - transition: 'background 0.2s', - flex: 1, - }} - onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'} - onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} - > - - - Servers - - - - - )} -
-
-
- ); -} - -function StarHoverableIcon(props) { - const [hover, setHover] = useState(false); - return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16 }} - > - {hover ? : } - - ); -} - -export { StarHoverableIcon }; \ No newline at end of file diff --git a/src/backend/config_editor/old_homeview.jsx b/src/backend/config_editor/old_homeview.jsx deleted file mode 100644 index 3f680a73..00000000 --- a/src/backend/config_editor/old_homeview.jsx +++ /dev/null @@ -1,380 +0,0 @@ -import React, { useState } from 'react'; -import { - Stack, - Paper, - Text, - Group, - Button, - ActionIcon, - ScrollArea, - TextInput, - Divider, - SimpleGrid, - Loader -} from '@mantine/core'; -import { - Star, - Folder, - File, - Trash2, - Plus, - History, - Bookmark, - Folders -} from 'lucide-react'; -import { StarHoverableIcon } from './FileViewer.jsx'; - -function compareServers(a, b) { - if (!a && !b) return true; - if (!a || !b) return false; - if (a.isLocal && b.isLocal) return true; - return a.name === b.name && a.ip === b.ip && a.port === b.port && a.user === b.user; -} - -export function HomeView({ onFileSelect, recentFiles, starredFiles, setStarredFiles, folderShortcuts, setFolderShortcuts, setFolder, setActiveTab, handleRemoveRecent, onSSHConnect, currentServer, isSSHConnecting }) { - const [newFolderPath, setNewFolderPath] = useState(''); - const [activeSection, setActiveSection] = useState('recent'); - - const handleStarFile = (file) => { - const isStarred = starredFiles.some(f => f.path === file.path); - if (isStarred) { - setStarredFiles(starredFiles.filter(f => f.path !== file.path)); - } else { - setStarredFiles([...starredFiles, file]); - } - }; - - const handleRemoveStarred = (file) => { - setStarredFiles(starredFiles.filter(f => f.path !== file.path)); - }; - - const handleRemoveFolder = (folder) => { - setFolderShortcuts(folderShortcuts.filter(f => f.path !== folder.path)); - }; - - const handleAddFolder = () => { - if (!newFolderPath) return; - setFolderShortcuts([...folderShortcuts, { path: newFolderPath, name: newFolderPath.split('/').pop(), server: currentServer }]); - setNewFolderPath(''); - }; - - const getServerSpecificData = (data) => { - if (!currentServer) return []; - return data.filter(item => compareServers(item.server, currentServer)); - }; - - const serverRecentFiles = getServerSpecificData(recentFiles); - const serverStarredFiles = getServerSpecificData(starredFiles); - const serverFolderShortcuts = getServerSpecificData(folderShortcuts); - - const handleFileClick = async (file) => { - if (file.server && !file.server.isLocal) { - if (onSSHConnect && (!currentServer || !compareServers(currentServer, file.server))) { - const connected = await onSSHConnect(file.server); - if (!connected) { - return; - } - } - const pathParts = file.path.split('/').filter(Boolean); - const fileName = pathParts.pop() || ''; - const folderPath = '/' + pathParts.join('/'); - onFileSelect(fileName, folderPath, file.server, file.path); - } else { - let parentFolder; - if (navigator.platform.includes('Win') && file.path.includes(':')) { - const lastSlashIndex = file.path.lastIndexOf('/'); - if (lastSlashIndex === -1) { - const driveLetter = file.path.substring(0, file.path.indexOf(':') + 1); - parentFolder = driveLetter + '/'; - } else { - parentFolder = file.path.substring(0, lastSlashIndex + 1); - } - } else { - const lastSlashIndex = file.path.lastIndexOf('/'); - parentFolder = lastSlashIndex === -1 ? '/' : file.path.substring(0, lastSlashIndex + 1); - } - onFileSelect(file.name, parentFolder); - } - }; - - const FileItem = ({ file, onStar, onRemove, showRemove }) => { - const parentFolder = file.path.substring(0, file.path.lastIndexOf('/')) || '/'; - const isSSHFile = file.server; - - return ( - e.currentTarget.style.backgroundColor = '#4A5568'} - onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} - onClick={() => handleFileClick(file)} - > -
- -
- - {file.name} - - {file.path} -
-
-
- { - e.stopPropagation(); - onStar(file); - }} - > - {starredFiles.some(f => f.path === file.path) ? ( - - ) : ( - - )} - - {showRemove && ( - { - e.stopPropagation(); - onRemove(file); - }} - > - - - )} -
-
- ); - }; - - const FolderItem = ({ folder, onRemove }) => ( - e.currentTarget.style.backgroundColor = '#4A5568'} - onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} - onClick={() => { - setFolder(folder.path); - }} - > - - -
- {folder.name} - {folder.path} -
- { - e.stopPropagation(); - onRemove(folder); - }} - > - - -
-
- ); - - return ( - - {!currentServer && ( - - - Please select a server from the sidebar to view your files - - - )} - {currentServer && ( - <> - - - Connected to: {currentServer.name} ({currentServer.user}@{currentServer.ip}:{currentServer.port}) - - - {isSSHConnecting ? ( - - - - - Connecting to SSH server... - - - - ) : ( - <> - - - - - - {activeSection === 'recent' && ( -
- - {serverRecentFiles.length === 0 ? ( - No recent files - ) : ( - serverRecentFiles.map(file => ( - - )) - )} - -
- )} - {activeSection === 'starred' && ( -
- - {serverStarredFiles.length === 0 ? ( - No starred files - ) : ( - serverStarredFiles.map(file => ( - - )) - )} - -
- )} - {activeSection === 'folders' && ( - - - setNewFolderPath(e.target.value)} - style={{ flex: 1 }} - styles={{ - input: { - backgroundColor: '#36414C', - borderColor: '#4A5568', - color: 'white', - '&::placeholder': { - color: '#A0AEC0' - } - } - }} - /> - - - -
- - {serverFolderShortcuts.length === 0 ? ( - No folder shortcuts - ) : ( - serverFolderShortcuts.map(folder => ( - - )) - )} - -
-
- )} - - )} - - )} -
- ); -} \ No newline at end of file diff --git a/src/backend/config_editor/old_ssh.js b/src/backend/config_editor/old_ssh.js deleted file mode 100644 index c70ebfee..00000000 --- a/src/backend/config_editor/old_ssh.js +++ /dev/null @@ -1,358 +0,0 @@ -const express = require('express'); -const http = require('http'); -const cors = require("cors"); -const bcrypt = require("bcrypt"); -const SSHClient = require("ssh2").Client; - -const app = express(); -const PORT = 8083; - -let sshConnection = null; -let isConnected = false; - -app.use(cors({ - origin: true, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] -})); -app.use(express.json()); - -const getReadableTimestamp = () => { - return new Intl.DateTimeFormat('en-US', { - dateStyle: 'medium', - timeStyle: 'medium', - timeZone: 'UTC', - }).format(new Date()); -}; - -const logger = { - info: (...args) => console.log(`💻 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args), - error: (...args) => console.error(`💻 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args), - warn: (...args) => console.warn(`💻 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args), - debug: (...args) => console.debug(`💻 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args) -}; - -const closeSSHConnection = () => { - if (sshConnection && isConnected) { - try { - sshConnection.end(); - sshConnection = null; - isConnected = false; - } catch (err) { - logger.error('Error closing SSH connection:', err.message); - } - } -}; - -const executeSSHCommand = (command) => { - return new Promise((resolve, reject) => { - if (!sshConnection || !isConnected) { - return reject(new Error('SSH connection not established')); - } - - sshConnection.exec(command, (err, stream) => { - if (err) { - logger.error('Error executing SSH command:', err.message); - return reject(err); - } - - let data = ''; - let error = ''; - - stream.on('data', (chunk) => { - data += chunk.toString(); - }); - - stream.stderr.on('data', (chunk) => { - error += chunk.toString(); - }); - - stream.on('close', (code) => { - if (code !== 0) { - logger.error(`SSH command failed with code ${code}:`, error); - return reject(new Error(`Command failed with code ${code}: ${error}`)); - } - resolve(data.trim()); - }); - }); - }); -}; - -app.post('/sshConnect', async (req, res) => { - try { - const hostConfig = req.body; - - if (!hostConfig || !hostConfig.ip || !hostConfig.user) { - return res.status(400).json({ - status: 'error', - message: 'Missing required host configuration (ip, user)' - }); - } - - closeSSHConnection(); - - sshConnection = new SSHClient(); - - const connectionConfig = { - host: hostConfig.ip, - port: hostConfig.port || 22, - username: hostConfig.user, - readyTimeout: 20000, - keepaliveInterval: 10000, - keepaliveCountMax: 3 - }; - - if (hostConfig.sshKey) { - connectionConfig.privateKey = hostConfig.sshKey; - } else if (hostConfig.password) { - connectionConfig.password = hostConfig.password; - } else { - return res.status(400).json({ - status: 'error', - message: 'Either password or SSH key must be provided' - }); - } - - sshConnection.on('ready', () => { - isConnected = true; - }); - - sshConnection.on('error', (err) => { - logger.error('SSH connection error:', err.message); - isConnected = false; - }); - - sshConnection.on('close', () => { - isConnected = false; - }); - - sshConnection.on('end', () => { - isConnected = false; - }); - - sshConnection.connect(connectionConfig); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('SSH connection timeout')); - }, 20000); - - sshConnection.once('ready', () => { - clearTimeout(timeout); - resolve(); - }); - - sshConnection.once('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - }); - - return res.status(200).json({ - status: 'success', - message: 'SSH connection established successfully' - }); - - } catch (error) { - logger.error('SSH connection failed:', error.message); - closeSSHConnection(); - - return res.status(500).json({ - status: 'error', - message: `SSH connection failed: ${error.message}` - }); - } -}); - -app.get('/listFiles', async (req, res) => { - try { - const { path = '/' } = req.query; - - if (!sshConnection || !isConnected) { - return res.status(400).json({ - status: 'error', - message: 'SSH connection not established. Please connect first.' - }); - } - - const lsCommand = `ls -la "${path}"`; - const result = await executeSSHCommand(lsCommand); - - const lines = result.split('\n').filter(line => line.trim()); - const files = []; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - const parts = line.split(/\s+/); - - if (parts.length >= 9) { - const permissions = parts[0]; - const links = parseInt(parts[1]) || 0; - const owner = parts[2]; - const group = parts[3]; - const size = parseInt(parts[4]) || 0; - const month = parts[5]; - const day = parseInt(parts[6]) || 0; - const timeOrYear = parts[7]; - const name = parts.slice(8).join(' '); - - const isDirectory = permissions.startsWith('d'); - const isLink = permissions.startsWith('l'); - - files.push({ - name: name, - type: isDirectory ? 'directory' : (isLink ? 'link' : 'file'), - size: size, - permissions: permissions, - owner: owner, - group: group, - modified: `${month} ${day} ${timeOrYear}`, - isDirectory: isDirectory, - isLink: isLink - }); - } - } - - return res.status(200).json({ - status: 'success', - path: path, - files: files, - totalCount: files.length - }); - - } catch (error) { - logger.error('Error listing files:', error.message); - - return res.status(500).json({ - status: 'error', - message: `Failed to list files: ${error.message}` - }); - } -}); - -app.post('/sshDisconnect', async (req, res) => { - try { - closeSSHConnection(); - - return res.status(200).json({ - status: 'success', - message: 'SSH connection disconnected successfully' - }); - } catch (error) { - logger.error('Error disconnecting SSH:', error.message); - - return res.status(500).json({ - status: 'error', - message: `Failed to disconnect: ${error.message}` - }); - } -}); - -app.get('/sshStatus', async (req, res) => { - return res.status(200).json({ - status: 'success', - connected: isConnected, - hasConnection: !!sshConnection - }); -}); - -app.get('/readFile', async (req, res) => { - try { - const { path: filePath } = req.query; - - if (!sshConnection || !isConnected) { - return res.status(400).json({ - status: 'error', - message: 'SSH connection not established. Please connect first.' - }); - } - - if (!filePath) { - return res.status(400).json({ - status: 'error', - message: 'File path is required' - }); - } - - const catCommand = `cat "${filePath}"`; - const result = await executeSSHCommand(catCommand); - - return res.status(200).json({ - status: 'success', - content: result, - path: filePath - }); - - } catch (error) { - logger.error('Error reading file:', error.message); - - return res.status(500).json({ - status: 'error', - message: `Failed to read file: ${error.message}` - }); - } -}); - -app.post('/writeFile', async (req, res) => { - try { - const { path: filePath, content } = req.body; - - if (!sshConnection || !isConnected) { - return res.status(400).json({ - status: 'error', - message: 'SSH connection not established. Please connect first.' - }); - } - - if (!filePath) { - return res.status(400).json({ - status: 'error', - message: 'File path is required' - }); - } - - if (content === undefined) { - return res.status(400).json({ - status: 'error', - message: 'File content is required' - }); - } - - const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - const echoCommand = `echo '${content.replace(/'/g, "'\"'\"'")}' > "${tempFile}"`; - await executeSSHCommand(echoCommand); - - const mvCommand = `mv "${tempFile}" "${filePath}"`; - await executeSSHCommand(mvCommand); - - return res.status(200).json({ - status: 'success', - message: 'File written successfully', - path: filePath - }); - - } catch (error) { - logger.error('Error writing file:', error.message); - - return res.status(500).json({ - status: 'error', - message: `Failed to write file: ${error.message}` - }); - } -}); - -process.on('SIGINT', () => { - closeSSHConnection(); - process.exit(0); -}); - -process.on('SIGTERM', () => { - closeSSHConnection(); - process.exit(0); -}); - -app.listen(PORT, () => { - logger.info(`SSH API listening at http://localhost:${PORT}`); -}); \ No newline at end of file diff --git a/src/backend/config_editor/old_tablist.jsx b/src/backend/config_editor/old_tablist.jsx deleted file mode 100644 index 8b78d6ac..00000000 --- a/src/backend/config_editor/old_tablist.jsx +++ /dev/null @@ -1,148 +0,0 @@ -import React from 'react'; -import { Button } from '@mantine/core'; -import { Home } from 'lucide-react'; - -export function TabList({ tabs, activeTab, setActiveTab, closeTab, onHomeClick }) { - return ( -
-
- -
- -
- {tabs.map((tab, i) => { - const isActive = tab.id === activeTab; - return ( -
- -
- -
- ); - })} -
-
- ); -} \ No newline at end of file diff --git a/src/backend/ssh/ssh.ts b/src/backend/ssh/ssh.ts index a24a9e74..b94dcb8c 100644 --- a/src/backend/ssh/ssh.ts +++ b/src/backend/ssh/ssh.ts @@ -83,11 +83,11 @@ wss.on('connection', (ws: WebSocket) => { key?: string; keyPassword?: string; keyType?: string; - authMethod?: string; + authType?: string; }; }) { const { cols, rows, hostConfig } = data; - const { ip, port, username, password, key, keyPassword, keyType, authMethod } = hostConfig; + const { ip, port, username, password, key, keyPassword, keyType, authType } = hostConfig; if (!username || typeof username !== 'string' || username.trim() === '') { logger.error('Invalid username provided'); @@ -216,11 +216,18 @@ wss.on('connection', (ws: WebSocket) => { ] } }; - if (authMethod === 'key' && key) { + if (authType === 'key' && key) { connectConfig.privateKey = key; if (keyPassword) { connectConfig.passphrase = keyPassword; } + if (keyType && keyType !== 'auto') { + connectConfig.privateKeyType = keyType; + } + } else if (authType === 'key') { + logger.error('SSH key authentication requested but no key provided'); + ws.send(JSON.stringify({ type: 'error', message: 'SSH key authentication requested but no key provided' })); + return; } else { connectConfig.password = password; } diff --git a/src/backend/ssh_tunnel/ssh_tunnel.ts b/src/backend/ssh_tunnel/ssh_tunnel.ts index ff8aa756..5ab0c868 100644 --- a/src/backend/ssh_tunnel/ssh_tunnel.ts +++ b/src/backend/ssh_tunnel/ssh_tunnel.ts @@ -1,9 +1,10 @@ import express from 'express'; import cors from 'cors'; -import { Client } from 'ssh2'; -import { exec } from 'child_process'; +import {Client} from 'ssh2'; +import {exec, spawn, ChildProcess} from 'child_process'; import chalk from 'chalk'; import axios from 'axios'; +import * as net from 'net'; const app = express(); app.use(cors({ @@ -54,10 +55,12 @@ const tunnelVerifications = new Map(); // tunnelName - const manualDisconnects = new Set(); // tunnelNames const verificationTimers = new Map(); // timer keys -> timeout const activeRetryTimers = new Map(); // tunnelName -> retry timer +const countdownIntervals = new Map(); // tunnelName -> countdown interval const retryExhaustedTunnels = new Set(); // tunnelNames const remoteClosureEvents = new Map(); // tunnelName -> count const hostConfigs = new Map(); // hostName -> hostConfig const tunnelConfigs = new Map(); // tunnelName -> tunnelConfig +const activeTunnelProcesses = new Map(); // tunnelName -> ChildProcess // Types interface TunnelConnection { @@ -149,7 +152,8 @@ const CONNECTION_STATES = { VERIFYING: "verifying", FAILED: "failed", UNSTABLE: "unstable", - RETRYING: "retrying" + RETRYING: "retrying", + WAITING: "waiting" } as const; const ERROR_TYPES = { @@ -225,11 +229,33 @@ function classifyError(errorMessage: string): ErrorType { // Cleanup and disconnect functions function cleanupTunnelResources(tunnelName: string): void { + // Kill any local ssh process for this tunnel + if (activeTunnelProcesses.has(tunnelName)) { + try { + const proc = activeTunnelProcesses.get(tunnelName); + if (proc) { + proc.kill('SIGTERM'); + logger.info(`Killed local ssh process for tunnel '${tunnelName}' (pid: ${proc.pid})`); + } + } catch (e) { + logger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e); + } + activeTunnelProcesses.delete(tunnelName); + } + if (activeTunnels.has(tunnelName)) { try { const conn = activeTunnels.get(tunnelName); - if (conn) conn.end(); - } catch (e) {} + if (conn) { + conn.end(); + logger.info(`Called conn.end() for tunnel '${tunnelName}'`); + conn.on('close', () => { + logger.info(`SSH2 Client connection closed for tunnel '${tunnelName}'`); + }); + } + } catch (e) { + logger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e); + } activeTunnels.delete(tunnelName); } @@ -238,7 +264,8 @@ function cleanupTunnelResources(tunnelName: string): void { if (verification?.timeout) clearTimeout(verification.timeout); try { verification?.conn.end(); - } catch (e) {} + } catch (e) { + } tunnelVerifications.delete(tunnelName); } @@ -260,6 +287,11 @@ function cleanupTunnelResources(tunnelName: string): void { clearTimeout(activeRetryTimers.get(tunnelName)!); activeRetryTimers.delete(tunnelName); } + + if (countdownIntervals.has(tunnelName)) { + clearInterval(countdownIntervals.get(tunnelName)!); + countdownIntervals.delete(tunnelName); + } } function resetRetryState(tunnelName: string): void { @@ -272,6 +304,11 @@ function resetRetryState(tunnelName: string): void { activeRetryTimers.delete(tunnelName); } + if (countdownIntervals.has(tunnelName)) { + clearInterval(countdownIntervals.get(tunnelName)!); + countdownIntervals.delete(tunnelName); + } + ['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => { const timerKey = `${tunnelName}${suffix}`; if (verificationTimers.has(timerKey)) { @@ -287,7 +324,8 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); - } catch (e) {} + } catch (e) { + } tunnelVerifications.delete(tunnelName); } @@ -375,7 +413,7 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, status: CONNECTION_STATES.RETRYING, retryCount: retryCount, maxRetries: maxRetries, - nextRetryIn: retryInterval/1000 + nextRetryIn: retryInterval / 1000 }); if (activeRetryTimers.has(tunnelName)) { @@ -383,11 +421,42 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, activeRetryTimers.delete(tunnelName); } + const initialNextRetryIn = Math.ceil(retryInterval / 1000); + let currentNextRetryIn = initialNextRetryIn; + + // Set initial WAITING status with countdown + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.WAITING, + retryCount: retryCount, + maxRetries: maxRetries, + nextRetryIn: currentNextRetryIn + }); + + // Update countdown every second + const countdownInterval = setInterval(() => { + currentNextRetryIn--; + if (currentNextRetryIn > 0) { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.WAITING, + retryCount: retryCount, + maxRetries: maxRetries, + nextRetryIn: currentNextRetryIn + }); + } + }, 1000); + + countdownIntervals.set(tunnelName, countdownInterval); + const timer = setTimeout(() => { + clearInterval(countdownInterval); + countdownIntervals.delete(tunnelName); activeRetryTimers.delete(tunnelName); if (!manualDisconnects.has(tunnelName)) { activeTunnels.delete(tunnelName); + connectSSHTunnel(tunnelConfig, retryCount); } }, retryInterval); @@ -437,7 +506,8 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, clearTimeout(verification.timeout); try { verification.conn.end(); - } catch (e) {} + } catch (e) { + } tunnelVerifications.delete(tunnelName); } @@ -452,7 +522,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, } } else { logger.error(`Verification failed for '${tunnelName}': ${failureReason}`); - + if (!manualDisconnects.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { connected: false, @@ -468,7 +538,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, function attemptVerification() { const testCmd = `nc -z localhost ${tunnelConfig.sourcePort}`; - + verificationConn.exec(testCmd, (err, stream) => { if (err) { cleanupVerification(false, `Verification command failed: ${err.message}`); @@ -554,6 +624,18 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, if (tunnelConfig.sourceKeyPassword) { connOptions.passphrase = tunnelConfig.sourceKeyPassword; } + // Add key type handling if specified + if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') { + connOptions.privateKeyType = tunnelConfig.sourceKeyType; + } + } else if (tunnelConfig.sourceAuthMethod === "key") { + logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: "SSH key authentication requested but no key provided" + }); + return; } else { connOptions.password = tunnelConfig.sourcePassword; } @@ -577,7 +659,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void conn.exec('echo "ping"', (err, stream) => { if (err) { clearInterval(pingInterval); - + if (!manualDisconnects.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { connected: false, @@ -585,7 +667,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void reason: "Ping failed" }); } - + activeTunnels.delete(tunnelName); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); return; @@ -594,7 +676,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void stream.on('close', (code: number) => { if (code !== 0) { clearInterval(pingInterval); - + if (!manualDisconnects.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { connected: false, @@ -602,7 +684,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void reason: "Ping command failed" }); } - + activeTunnels.delete(tunnelName); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); } @@ -610,7 +692,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void stream.on('error', (err: Error) => { clearInterval(pingInterval); - + if (!manualDisconnects.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { connected: false, @@ -618,7 +700,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void reason: "Ping stream error" }); } - + activeTunnels.delete(tunnelName); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); }); @@ -644,17 +726,22 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { const isRetryAfterRemoteClosure = remoteClosureEvents.get(tunnelName) && retryAttempt > 0; - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.CONNECTING, - retryCount: retryAttempt > 0 ? retryAttempt : undefined, - isRemoteRetry: !!isRetryAfterRemoteClosure - }); + // Only set status to CONNECTING if we're not already in WAITING state + const currentStatus = connectionStatus.get(tunnelName); + + if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.CONNECTING, + retryCount: retryAttempt > 0 ? retryAttempt : undefined, + isRemoteRetry: !!isRetryAfterRemoteClosure + }); + } if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) { logger.error(`Invalid connection details for '${tunnelName}'`); - broadcastTunnelStatus(tunnelName, { - connected: false, + broadcastTunnelStatus(tunnelName, { + connected: false, status: CONNECTION_STATES.FAILED, reason: "Missing required connection details" }); @@ -671,7 +758,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { try { conn.end(); - } catch (e) {} + } catch (e) { + } activeTunnels.delete(tunnelName); @@ -751,16 +839,22 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { let tunnelCmd: string; if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) { - tunnelCmd = `ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`; + // For SSH key authentication, we need to create a temporary key file + const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`; + tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -4 -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`; } else { - tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`; + 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}`; } + conn.exec(tunnelCmd, (err, stream) => { if (err) { logger.error(`Connection error for '${tunnelName}': ${err.message}`); - try { conn.end(); } catch(e) {} + try { + conn.end(); + } catch (e) { + } activeTunnels.delete(tunnelName); @@ -793,7 +887,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); - } catch (e) {} + } catch (e) { + } tunnelVerifications.delete(tunnelName); } @@ -828,13 +923,24 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { } }); + stream.stdout?.on("data", (data: Buffer) => { + // Ignore stdout data + }); + + stream.on("error", (err: Error) => { + // Ignore stream errors + }); + stream.stderr.on("data", (data) => { - const errorMsg = data.toString(); + const errorMsg = data.toString().trim(); const isNonRetryableError = errorMsg.includes("Permission denied") || errorMsg.includes("Authentication failed") || errorMsg.includes("failed for listen port") || - errorMsg.includes("address already in use"); + errorMsg.includes("address already in use") || + errorMsg.includes("bind: Address already in use") || + errorMsg.includes("channel 0: open failed") || + errorMsg.includes("remote port forwarding failed"); const isRemoteHostClosure = errorMsg.includes("closed by remote host") || errorMsg.includes("connection reset by peer") || @@ -923,15 +1029,79 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { }; if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { + + // Validate SSH key format + if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) { + logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: "Invalid SSH key format" + }); + return; + } + connOptions.privateKey = tunnelConfig.sourceSSHKey; if (tunnelConfig.sourceKeyPassword) { connOptions.passphrase = tunnelConfig.sourceKeyPassword; } + // Add key type handling if specified + if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') { + connOptions.privateKeyType = tunnelConfig.sourceKeyType; + } + } else if (tunnelConfig.sourceAuthMethod === "key") { + logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: "SSH key authentication requested but no key provided" + }); + return; } else { connOptions.password = tunnelConfig.sourcePassword; } - conn.connect(connOptions); + + // Test basic network connectivity first + const testSocket = new net.Socket(); + testSocket.setTimeout(5000); + + testSocket.on('connect', () => { + testSocket.destroy(); + + // Only update status to CONNECTING if we're not already in WAITING state + const currentStatus = connectionStatus.get(tunnelName); + if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.CONNECTING, + retryCount: retryAttempt > 0 ? retryAttempt : undefined, + isRemoteRetry: !!isRetryAfterRemoteClosure + }); + } + + conn.connect(connOptions); + }); + + testSocket.on('timeout', () => { + testSocket.destroy(); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: "Network connectivity test failed - server not reachable" + }); + }); + + testSocket.on('error', (err: any) => { + testSocket.destroy(); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: `Network connectivity test failed - ${err.message}` + }); + }); + + testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP); } // Express API endpoints @@ -940,46 +1110,47 @@ app.get('/status', (req, res) => { }); app.get('/status/:tunnelName', (req, res) => { - const { tunnelName } = req.params; + const {tunnelName} = req.params; const status = connectionStatus.get(tunnelName); - + if (!status) { - return res.status(404).json({ error: 'Tunnel not found' }); + return res.status(404).json({error: 'Tunnel not found'}); } - - res.json({ name: tunnelName, status }); + + res.json({name: tunnelName, status}); }); app.post('/connect', (req, res) => { const tunnelConfig: TunnelConfig = req.body; - + if (!tunnelConfig || !tunnelConfig.name) { - return res.status(400).json({ error: 'Invalid tunnel configuration' }); + return res.status(400).json({error: 'Invalid tunnel configuration'}); } const tunnelName = tunnelConfig.name; - + + // Reset retry state for new connection manualDisconnects.delete(tunnelName); retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); - + // Store tunnel config tunnelConfigs.set(tunnelName, tunnelConfig); - + // Start connection connectSSHTunnel(tunnelConfig, 0); - - res.json({ message: 'Connection request received', tunnelName }); + + res.json({message: 'Connection request received', tunnelName}); }); app.post('/disconnect', (req, res) => { - const { tunnelName } = req.body; - + const {tunnelName} = req.body; + if (!tunnelName) { - return res.status(400).json({ error: 'Tunnel name required' }); + return res.status(400).json({error: 'Tunnel name required'}); } - + manualDisconnects.add(tunnelName); retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); @@ -1003,7 +1174,7 @@ app.post('/disconnect', (req, res) => { manualDisconnects.delete(tunnelName); }, 5000); - res.json({ message: 'Disconnect request received', tunnelName }); + res.json({message: 'Disconnect request received', tunnelName}); }); // Auto-start functionality @@ -1026,8 +1197,8 @@ async function initializeAutoStartTunnels(): Promise { for (const tunnelConnection of host.tunnelConnections) { if (tunnelConnection.autoStart) { // Find the endpoint host - const endpointHost = hosts.find(h => - h.name === tunnelConnection.endpointHost || + const endpointHost = hosts.find(h => + h.name === tunnelConnection.endpointHost || `${h.username}@${h.ip}` === tunnelConnection.endpointHost ); @@ -1071,7 +1242,7 @@ async function initializeAutoStartTunnels(): Promise { // Start each auto-start tunnel for (const tunnelConfig of autoStartTunnels) { tunnelConfigs.set(tunnelConfig.name, tunnelConfig); - + // Start the tunnel with a delay to avoid overwhelming the system setTimeout(() => { connectSSHTunnel(tunnelConfig, 0); @@ -1103,44 +1274,44 @@ app.get('/tunnels', (req, res) => { // Update tunnel configuration app.put('/tunnel/:name', (req, res) => { - const { name } = req.params; + const {name} = req.params; const tunnelConfig: TunnelConfig = req.body; - + if (!tunnelConfig || !tunnelConfig.name) { - return res.status(400).json({ error: 'Invalid tunnel configuration' }); + 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 }); + + res.json({message: 'Tunnel configuration updated', name}); }); // Delete tunnel configuration app.delete('/tunnel/:name', (req, res) => { - const { name } = req.params; - + 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 }); + + res.json({message: 'Tunnel deleted', name}); }); // Start the server