Ssh tunnel backup before forwardIn rewrite
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
||||||
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||||
import { getSSHHosts } from "@/apps/SSH/ssh-axios";
|
import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel } from "@/apps/SSH/ssh-axios";
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
interface ConfigEditorProps {
|
interface ConfigEditorProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
@@ -76,8 +75,7 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
// Poll backend for tunnel statuses
|
// Poll backend for tunnel statuses
|
||||||
const fetchTunnelStatuses = useCallback(async () => {
|
const fetchTunnelStatuses = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('http://localhost:8083/status');
|
const statusData = await getTunnelStatuses();
|
||||||
const statusData = res.data || {};
|
|
||||||
|
|
||||||
// Convert tunnel statuses to host statuses
|
// Convert tunnel statuses to host statuses
|
||||||
const newHostStatuses: Record<number, HostStatus> = {};
|
const newHostStatuses: Record<number, HostStatus> = {};
|
||||||
@@ -95,37 +93,17 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (hostTunnelStatuses.length > 0) {
|
if (hostTunnelStatuses.length > 0) {
|
||||||
// Determine overall host status based on tunnel statuses
|
// Just use the first tunnel's status for now - simplify
|
||||||
const connectedTunnels = hostTunnelStatuses.filter(s => s.status === 'connected');
|
const firstTunnelStatus = hostTunnelStatuses[0];
|
||||||
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] = {
|
newHostStatuses[host.id] = {
|
||||||
connectionState: overallStatus,
|
connectionState: firstTunnelStatus.status,
|
||||||
statusReason,
|
statusReason: firstTunnelStatus.reason,
|
||||||
statusErrorType: failedTunnels[0]?.errorType,
|
statusErrorType: firstTunnelStatus.errorType,
|
||||||
statusRetryCount: connectingTunnels.find(s => s.status === 'retrying')?.retryCount,
|
statusRetryCount: firstTunnelStatus.retryCount,
|
||||||
statusMaxRetries: connectingTunnels.find(s => s.status === 'retrying')?.maxRetries,
|
statusMaxRetries: firstTunnelStatus.maxRetries,
|
||||||
statusNextRetryIn: connectingTunnels.find(s => s.status === 'retrying')?.nextRetryIn,
|
statusNextRetryIn: firstTunnelStatus.nextRetryIn,
|
||||||
statusRetryExhausted: failedTunnels.some(s => s.retryExhausted),
|
statusRetryExhausted: firstTunnelStatus.retryExhausted,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Set default disconnected status
|
// Set default disconnected status
|
||||||
@@ -159,11 +137,7 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Immediately set to CONNECTING for instant UI feedback
|
// Let the backend handle the status updates
|
||||||
setHostStatuses(prev => ({
|
|
||||||
...prev,
|
|
||||||
[hostId]: { ...prev[hostId], connectionState: "connecting" }
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For each tunnel connection, create a tunnel configuration
|
// For each tunnel connection, create a tunnel configuration
|
||||||
@@ -206,14 +180,10 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
isPinned: host.pin
|
isPinned: host.pin
|
||||||
};
|
};
|
||||||
|
|
||||||
await axios.post('http://localhost:8083/connect', tunnelConfig);
|
await connectTunnel(tunnelConfig);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Reset status on error
|
// Let the backend handle error status updates
|
||||||
setHostStatuses(prev => ({
|
|
||||||
...prev,
|
|
||||||
[hostId]: { ...prev[hostId], connectionState: "failed", statusReason: "Failed to connect" }
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,17 +191,13 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
|||||||
const host = hosts.find(h => h.id === hostId);
|
const host = hosts.find(h => h.id === hostId);
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
// Immediately set to DISCONNECTING for instant UI feedback
|
// Let the backend handle the status updates
|
||||||
setHostStatuses(prev => ({
|
|
||||||
...prev,
|
|
||||||
[hostId]: { ...prev[hostId], connectionState: "disconnecting" }
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Disconnect all tunnels for this host
|
// Disconnect all tunnels for this host
|
||||||
for (const tunnelConnection of host.tunnelConnections) {
|
for (const tunnelConnection of host.tunnelConnections) {
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`;
|
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) {
|
} catch (err) {
|
||||||
// Silent error handling
|
// Silent error handling
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const CONNECTION_STATES = {
|
|||||||
FAILED: "failed",
|
FAILED: "failed",
|
||||||
UNSTABLE: "unstable",
|
UNSTABLE: "unstable",
|
||||||
RETRYING: "retrying",
|
RETRYING: "retrying",
|
||||||
|
WAITING: "waiting",
|
||||||
DISCONNECTING: "disconnecting"
|
DISCONNECTING: "disconnecting"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ export function SSHTunnelObject({
|
|||||||
case "CONNECTING":
|
case "CONNECTING":
|
||||||
case "VERIFYING":
|
case "VERIFYING":
|
||||||
case "RETRYING":
|
case "RETRYING":
|
||||||
|
case "WAITING":
|
||||||
return "bg-yellow-500";
|
return "bg-yellow-500";
|
||||||
case "FAILED":
|
case "FAILED":
|
||||||
return "bg-red-500";
|
return "bg-red-500";
|
||||||
@@ -88,27 +90,12 @@ export function SSHTunnelObject({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (state: string) => {
|
const getStatusText = (state: string) => {
|
||||||
const upperState = state.toUpperCase();
|
// Just capitalize the first letter of the status from backend
|
||||||
switch (upperState) {
|
return state.charAt(0).toUpperCase() + state.slice(1);
|
||||||
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";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isConnected = connectionState === "CONNECTED" || connectionState === "connected";
|
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";
|
const isDisconnecting = connectionState === "DISCONNECTING" || connectionState === "disconnecting";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -193,9 +180,9 @@ export function SSHTunnelObject({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Retry Info */}
|
{/* Retry Info */}
|
||||||
{connectionState === "RETRYING" && statusRetryCount && statusMaxRetries && (
|
{(connectionState === "retrying" || connectionState === "waiting") && statusRetryCount && statusMaxRetries && (
|
||||||
<div className="mb-2 text-xs text-yellow-600 bg-yellow-500/10 rounded px-2 py-1 border border-yellow-500/20">
|
<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}
|
{connectionState === "waiting" ? "Waiting" : "Retry"} {statusRetryCount}/{statusMaxRetries}
|
||||||
{statusNextRetryIn && (
|
{statusNextRetryIn && (
|
||||||
<span> • Next retry in {statusNextRetryIn}s</span>
|
<span> • Next retry in {statusNextRetryIn}s</span>
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +200,7 @@ export function SSHTunnelObject({
|
|||||||
{isConnecting ? (
|
{isConnecting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Connecting...
|
{getStatusText(connectionState)}...
|
||||||
</>
|
</>
|
||||||
) : isConnected ? (
|
) : isConnected ? (
|
||||||
"Connected"
|
"Connected"
|
||||||
|
|||||||
@@ -44,6 +44,43 @@ interface SSHHost {
|
|||||||
updatedAt: string;
|
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
|
// Determine the base URL based on environment
|
||||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
|
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 {
|
function getCookie(name: string): string | undefined {
|
||||||
const value = `; ${document.cookie}`;
|
const value = `; ${document.cookie}`;
|
||||||
const parts = value.split(`; ${name}=`);
|
const parts = value.split(`; ${name}=`);
|
||||||
@@ -71,6 +115,14 @@ api.interceptors.request.use((config) => {
|
|||||||
return 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
|
// Get all SSH hosts
|
||||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
export async function getSSHHosts(): Promise<SSHHost[]> {
|
||||||
try {
|
try {
|
||||||
@@ -226,4 +278,45 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tunnel-related functions
|
||||||
|
|
||||||
|
// Get tunnel statuses
|
||||||
|
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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 };
|
export { api };
|
||||||
@@ -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}`);
|
|
||||||
});
|
|
||||||
@@ -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}`);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
|
||||||
<Paper
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#36414C',
|
|
||||||
border: '1px solid #4A5568',
|
|
||||||
cursor: 'pointer',
|
|
||||||
height: '100%',
|
|
||||||
maxWidth: '100%',
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingRight: 0,
|
|
||||||
}}
|
|
||||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'}
|
|
||||||
onClick={() => handleFileClick(file)}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
maxWidth: 'calc(100% - 40px)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
<File size={16} color={isSSHFile ? "#4299E1" : "#A0AEC0"} style={{ userSelect: 'none', flexShrink: 0 }} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0, marginLeft: 8 }}>
|
|
||||||
<Text size="sm" color="white" style={{ lineHeight: 1.2, wordBreak: 'break-word', whiteSpace: 'normal', userSelect: 'none', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{file.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" color="dimmed" style={{ lineHeight: 1.2, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.path}</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
paddingLeft: 4
|
|
||||||
}}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="yellow"
|
|
||||||
style={{ borderRadius: '50%', marginLeft: 0, background: 'none', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onStar(file);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{starredFiles.some(f => f.path === file.path) ? (
|
|
||||||
<Star size={16} fill="currentColor" />
|
|
||||||
) : (
|
|
||||||
<StarHoverableIcon size={16} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
{showRemove && (
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
style={{ borderRadius: '50%', marginLeft: 0, background: 'none', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove(file);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FolderItem = ({ folder, onRemove }) => (
|
|
||||||
<Paper
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#36414C',
|
|
||||||
border: '1px solid #4A5568',
|
|
||||||
cursor: 'pointer',
|
|
||||||
height: '100%',
|
|
||||||
maxWidth: '100%',
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'}
|
|
||||||
onClick={() => {
|
|
||||||
setFolder(folder.path);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group spacing={4} align="flex-start" noWrap>
|
|
||||||
<Folder size={16} color="#4299E1" style={{ marginTop: 2 }} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Text size="sm" color="white" style={{ lineHeight: 1.2, wordBreak: 'break-word', whiteSpace: 'normal', userSelect: 'none', overflow: 'hidden', textOverflow: 'ellipsis' }}>{folder.name}</Text>
|
|
||||||
<Text size="xs" color="dimmed" style={{ lineHeight: 1.2, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{folder.path}</Text>
|
|
||||||
</div>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
style={{ borderRadius: '50%', marginLeft: 0, background: 'none', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove(folder);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
h="100%"
|
|
||||||
spacing="md"
|
|
||||||
p="md"
|
|
||||||
style={{
|
|
||||||
color: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!currentServer && (
|
|
||||||
<Paper p="md" style={{ backgroundColor: '#2F3740', border: '1px solid #4A5568' }}>
|
|
||||||
<Text color="dimmed" align="center" size="lg">
|
|
||||||
Please select a server from the sidebar to view your files
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
{currentServer && (
|
|
||||||
<>
|
|
||||||
<Paper p="xs" style={{ backgroundColor: '#2F3740', border: '1px solid #4A5568' }}>
|
|
||||||
<Text color="white" size="sm" weight={500}>
|
|
||||||
Connected to: {currentServer.name} ({currentServer.user}@{currentServer.ip}:{currentServer.port})
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
{isSSHConnecting ? (
|
|
||||||
<Paper p="md" style={{ backgroundColor: '#2F3740', border: '1px solid #4A5568' }}>
|
|
||||||
<Group justify="center" spacing="md">
|
|
||||||
<Loader size="sm" color="#4299E1" />
|
|
||||||
<Text color="dimmed" align="center" size="lg">
|
|
||||||
Connecting to SSH server...
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Group spacing="md" mb="md">
|
|
||||||
<Button
|
|
||||||
variant="filled"
|
|
||||||
color="blue"
|
|
||||||
leftSection={<History size={18} />}
|
|
||||||
onClick={() => setActiveSection('recent')}
|
|
||||||
style={{ backgroundColor: activeSection === 'recent' ? '#36414C' : '#4A5568', color: 'white', borderColor: '#4A5568', transition: 'background 0.2s' }}
|
|
||||||
onMouseOver={e => e.currentTarget.style.backgroundColor = activeSection === 'recent' ? '#36414C' : '#36414C'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.backgroundColor = activeSection === 'recent' ? '#36414C' : '#4A5568'}
|
|
||||||
>
|
|
||||||
Recent
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="filled"
|
|
||||||
color="yellow"
|
|
||||||
leftSection={<Bookmark size={18} />}
|
|
||||||
onClick={() => setActiveSection('starred')}
|
|
||||||
style={{ backgroundColor: activeSection === 'starred' ? '#36414C' : '#4A5568', color: 'white', borderColor: '#4A5568', transition: 'background 0.2s' }}
|
|
||||||
onMouseOver={e => e.currentTarget.style.backgroundColor = activeSection === 'starred' ? '#36414C' : '#36414C'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.backgroundColor = activeSection === 'starred' ? '#36414C' : '#4A5568'}
|
|
||||||
>
|
|
||||||
Starred
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="filled"
|
|
||||||
color="teal"
|
|
||||||
leftSection={<Folders size={18} />}
|
|
||||||
onClick={() => setActiveSection('folders')}
|
|
||||||
style={{ backgroundColor: activeSection === 'folders' ? '#36414C' : '#4A5568', color: 'white', borderColor: '#4A5568', transition: 'background 0.2s' }}
|
|
||||||
onMouseOver={e => e.currentTarget.style.backgroundColor = activeSection === 'folders' ? '#36414C' : '#36414C'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.backgroundColor = activeSection === 'folders' ? '#36414C' : '#4A5568'}
|
|
||||||
>
|
|
||||||
Folder Shortcuts
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
{activeSection === 'recent' && (
|
|
||||||
<div style={{ height: 'calc(100vh - 200px)', overflow: 'hidden' }}>
|
|
||||||
<SimpleGrid cols={3} spacing="md">
|
|
||||||
{serverRecentFiles.length === 0 ? (
|
|
||||||
<Text color="dimmed" align="center" style={{ gridColumn: '1 / -1', padding: '2rem' }}>No recent files</Text>
|
|
||||||
) : (
|
|
||||||
serverRecentFiles.map(file => (
|
|
||||||
<FileItem
|
|
||||||
key={file.path}
|
|
||||||
file={file}
|
|
||||||
onStar={handleStarFile}
|
|
||||||
onRemove={handleRemoveRecent}
|
|
||||||
showRemove={true}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeSection === 'starred' && (
|
|
||||||
<div style={{ height: 'calc(100vh - 200px)', overflow: 'hidden' }}>
|
|
||||||
<SimpleGrid cols={3} spacing="md">
|
|
||||||
{serverStarredFiles.length === 0 ? (
|
|
||||||
<Text color="dimmed" align="center" style={{ gridColumn: '1 / -1', padding: '2rem' }}>No starred files</Text>
|
|
||||||
) : (
|
|
||||||
serverStarredFiles.map(file => (
|
|
||||||
<FileItem
|
|
||||||
key={file.path}
|
|
||||||
file={file}
|
|
||||||
onStar={handleStarFile}
|
|
||||||
showRemove={false}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeSection === 'folders' && (
|
|
||||||
<Stack spacing="md">
|
|
||||||
<Group>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Enter folder path"
|
|
||||||
value={newFolderPath}
|
|
||||||
onChange={(e) => setNewFolderPath(e.target.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
backgroundColor: '#36414C',
|
|
||||||
borderColor: '#4A5568',
|
|
||||||
color: 'white',
|
|
||||||
'&::placeholder': {
|
|
||||||
color: '#A0AEC0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
leftSection={<Plus size={16} />}
|
|
||||||
onClick={handleAddFolder}
|
|
||||||
variant="filled"
|
|
||||||
color="blue"
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#36414C',
|
|
||||||
border: '1px solid #4A5568',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: '#4A5568'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Divider color="#4A5568" />
|
|
||||||
<div style={{ height: 'calc(100vh - 280px)', overflow: 'hidden' }}>
|
|
||||||
<SimpleGrid cols={3} spacing="md">
|
|
||||||
{serverFolderShortcuts.length === 0 ? (
|
|
||||||
<Text color="dimmed" align="center" style={{ gridColumn: '1 / -1', padding: '2rem' }}>No folder shortcuts</Text>
|
|
||||||
) : (
|
|
||||||
serverFolderShortcuts.map(folder => (
|
|
||||||
<FolderItem
|
|
||||||
key={folder.path}
|
|
||||||
folder={folder}
|
|
||||||
onRemove={handleRemoveFolder}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}`);
|
|
||||||
});
|
|
||||||
@@ -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 (
|
|
||||||
<div style={{
|
|
||||||
height: '40px',
|
|
||||||
backgroundColor: '#2F3740',
|
|
||||||
borderRadius: '4px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
flex: 1,
|
|
||||||
margin: '0 8px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '0 4px',
|
|
||||||
overflowX: 'auto',
|
|
||||||
width: '100%',
|
|
||||||
scrollbarWidth: 'thin',
|
|
||||||
scrollbarColor: '#4A5568 #2F3740'
|
|
||||||
}}>
|
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
div::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
div::-webkit-scrollbar-track {
|
|
||||||
background: #2F3740;
|
|
||||||
}
|
|
||||||
div::-webkit-scrollbar-thumb {
|
|
||||||
background: #4A5568;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: activeTab === 'home' ? '#36414C' : '#2F3740',
|
|
||||||
borderRadius: '4px',
|
|
||||||
height: '32px',
|
|
||||||
minWidth: '48px',
|
|
||||||
marginRight: '4px',
|
|
||||||
border: '1px solid #4A5568',
|
|
||||||
overflow: 'hidden',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={onHomeClick}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
padding: '0 8px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
minWidth: '48px',
|
|
||||||
borderRadius: 0,
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.backgroundColor = 'transparent'}
|
|
||||||
>
|
|
||||||
<Home size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{tabs.map((tab, i) => {
|
|
||||||
const isActive = tab.id === activeTab;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tab.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: isActive ? '#36414C' : '#2F3740',
|
|
||||||
borderRadius: '4px',
|
|
||||||
height: '32px',
|
|
||||||
minWidth: '120px',
|
|
||||||
maxWidth: '200px',
|
|
||||||
marginRight: '4px',
|
|
||||||
border: '1px solid #4A5568',
|
|
||||||
overflow: 'hidden',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: '100%',
|
|
||||||
padding: '0 8px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
textAlign: 'left',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
borderRadius: 0,
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.backgroundColor = 'transparent'}
|
|
||||||
>
|
|
||||||
{tab.name}
|
|
||||||
</Button>
|
|
||||||
<div style={{
|
|
||||||
width: '1px',
|
|
||||||
height: '16px',
|
|
||||||
backgroundColor: '#4A5568',
|
|
||||||
margin: '0 4px'
|
|
||||||
}} />
|
|
||||||
<Button
|
|
||||||
onClick={() => closeTab(tab.id)}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
padding: '0 8px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
minWidth: '32px',
|
|
||||||
borderRadius: 0,
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.backgroundColor = 'transparent'}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -83,11 +83,11 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
authMethod?: string;
|
authType?: string;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const { cols, rows, hostConfig } = data;
|
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() === '') {
|
if (!username || typeof username !== 'string' || username.trim() === '') {
|
||||||
logger.error('Invalid username provided');
|
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;
|
connectConfig.privateKey = key;
|
||||||
if (keyPassword) {
|
if (keyPassword) {
|
||||||
connectConfig.passphrase = 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 {
|
} else {
|
||||||
connectConfig.password = password;
|
connectConfig.password = password;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import {Client} from 'ssh2';
|
import {Client} from 'ssh2';
|
||||||
import { exec } from 'child_process';
|
import {exec, spawn, ChildProcess} from 'child_process';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import * as net from 'net';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
@@ -54,10 +55,12 @@ const tunnelVerifications = new Map<string, VerificationData>(); // tunnelName -
|
|||||||
const manualDisconnects = new Set<string>(); // tunnelNames
|
const manualDisconnects = new Set<string>(); // tunnelNames
|
||||||
const verificationTimers = new Map<string, NodeJS.Timeout>(); // timer keys -> timeout
|
const verificationTimers = new Map<string, NodeJS.Timeout>(); // timer keys -> timeout
|
||||||
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer
|
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer
|
||||||
|
const countdownIntervals = new Map<string, NodeJS.Timeout>(); // tunnelName -> countdown interval
|
||||||
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
|
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
|
||||||
const remoteClosureEvents = new Map<string, number>(); // tunnelName -> count
|
const remoteClosureEvents = new Map<string, number>(); // tunnelName -> count
|
||||||
const hostConfigs = new Map<string, HostConfig>(); // hostName -> hostConfig
|
const hostConfigs = new Map<string, HostConfig>(); // hostName -> hostConfig
|
||||||
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
|
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
|
||||||
|
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface TunnelConnection {
|
interface TunnelConnection {
|
||||||
@@ -149,7 +152,8 @@ const CONNECTION_STATES = {
|
|||||||
VERIFYING: "verifying",
|
VERIFYING: "verifying",
|
||||||
FAILED: "failed",
|
FAILED: "failed",
|
||||||
UNSTABLE: "unstable",
|
UNSTABLE: "unstable",
|
||||||
RETRYING: "retrying"
|
RETRYING: "retrying",
|
||||||
|
WAITING: "waiting"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ERROR_TYPES = {
|
const ERROR_TYPES = {
|
||||||
@@ -225,11 +229,33 @@ function classifyError(errorMessage: string): ErrorType {
|
|||||||
|
|
||||||
// Cleanup and disconnect functions
|
// Cleanup and disconnect functions
|
||||||
function cleanupTunnelResources(tunnelName: string): void {
|
function cleanupTunnelResources(tunnelName: string): void {
|
||||||
|
// Kill any local ssh process for this tunnel
|
||||||
|
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)) {
|
if (activeTunnels.has(tunnelName)) {
|
||||||
try {
|
try {
|
||||||
const conn = activeTunnels.get(tunnelName);
|
const conn = activeTunnels.get(tunnelName);
|
||||||
if (conn) conn.end();
|
if (conn) {
|
||||||
} catch (e) {}
|
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);
|
activeTunnels.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +264,8 @@ function cleanupTunnelResources(tunnelName: string): void {
|
|||||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||||
try {
|
try {
|
||||||
verification?.conn.end();
|
verification?.conn.end();
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
}
|
||||||
tunnelVerifications.delete(tunnelName);
|
tunnelVerifications.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +287,11 @@ function cleanupTunnelResources(tunnelName: string): void {
|
|||||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||||
activeRetryTimers.delete(tunnelName);
|
activeRetryTimers.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (countdownIntervals.has(tunnelName)) {
|
||||||
|
clearInterval(countdownIntervals.get(tunnelName)!);
|
||||||
|
countdownIntervals.delete(tunnelName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetRetryState(tunnelName: string): void {
|
function resetRetryState(tunnelName: string): void {
|
||||||
@@ -272,6 +304,11 @@ function resetRetryState(tunnelName: string): void {
|
|||||||
activeRetryTimers.delete(tunnelName);
|
activeRetryTimers.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (countdownIntervals.has(tunnelName)) {
|
||||||
|
clearInterval(countdownIntervals.get(tunnelName)!);
|
||||||
|
countdownIntervals.delete(tunnelName);
|
||||||
|
}
|
||||||
|
|
||||||
['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => {
|
['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => {
|
||||||
const timerKey = `${tunnelName}${suffix}`;
|
const timerKey = `${tunnelName}${suffix}`;
|
||||||
if (verificationTimers.has(timerKey)) {
|
if (verificationTimers.has(timerKey)) {
|
||||||
@@ -287,7 +324,8 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
const verification = tunnelVerifications.get(tunnelName);
|
const verification = tunnelVerifications.get(tunnelName);
|
||||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||||
verification?.conn.end();
|
verification?.conn.end();
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
}
|
||||||
tunnelVerifications.delete(tunnelName);
|
tunnelVerifications.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,11 +421,42 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
activeRetryTimers.delete(tunnelName);
|
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(() => {
|
const timer = setTimeout(() => {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
countdownIntervals.delete(tunnelName);
|
||||||
activeRetryTimers.delete(tunnelName);
|
activeRetryTimers.delete(tunnelName);
|
||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName)) {
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
connectSSHTunnel(tunnelConfig, retryCount);
|
connectSSHTunnel(tunnelConfig, retryCount);
|
||||||
}
|
}
|
||||||
}, retryInterval);
|
}, retryInterval);
|
||||||
@@ -437,7 +506,8 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
|||||||
clearTimeout(verification.timeout);
|
clearTimeout(verification.timeout);
|
||||||
try {
|
try {
|
||||||
verification.conn.end();
|
verification.conn.end();
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
}
|
||||||
tunnelVerifications.delete(tunnelName);
|
tunnelVerifications.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +624,18 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
|||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = 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 {
|
} else {
|
||||||
connOptions.password = tunnelConfig.sourcePassword;
|
connOptions.password = tunnelConfig.sourcePassword;
|
||||||
}
|
}
|
||||||
@@ -644,12 +726,17 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
const isRetryAfterRemoteClosure = remoteClosureEvents.get(tunnelName) && retryAttempt > 0;
|
const isRetryAfterRemoteClosure = remoteClosureEvents.get(tunnelName) && retryAttempt > 0;
|
||||||
|
|
||||||
|
// 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, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.CONNECTING,
|
status: CONNECTION_STATES.CONNECTING,
|
||||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined,
|
retryCount: retryAttempt > 0 ? retryAttempt : undefined,
|
||||||
isRemoteRetry: !!isRetryAfterRemoteClosure
|
isRemoteRetry: !!isRetryAfterRemoteClosure
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) {
|
if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) {
|
||||||
logger.error(`Invalid connection details for '${tunnelName}'`);
|
logger.error(`Invalid connection details for '${tunnelName}'`);
|
||||||
@@ -671,7 +758,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
conn.end();
|
conn.end();
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
}
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
@@ -751,16 +839,22 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
let tunnelCmd: string;
|
let tunnelCmd: string;
|
||||||
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
|
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 {
|
} 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) => {
|
conn.exec(tunnelCmd, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error(`Connection error for '${tunnelName}': ${err.message}`);
|
logger.error(`Connection error for '${tunnelName}': ${err.message}`);
|
||||||
|
|
||||||
try { conn.end(); } catch(e) {}
|
try {
|
||||||
|
conn.end();
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
@@ -793,7 +887,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
const verification = tunnelVerifications.get(tunnelName);
|
const verification = tunnelVerifications.get(tunnelName);
|
||||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||||
verification?.conn.end();
|
verification?.conn.end();
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
}
|
||||||
tunnelVerifications.delete(tunnelName);
|
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) => {
|
stream.stderr.on("data", (data) => {
|
||||||
const errorMsg = data.toString();
|
const errorMsg = data.toString().trim();
|
||||||
|
|
||||||
const isNonRetryableError = errorMsg.includes("Permission denied") ||
|
const isNonRetryableError = errorMsg.includes("Permission denied") ||
|
||||||
errorMsg.includes("Authentication failed") ||
|
errorMsg.includes("Authentication failed") ||
|
||||||
errorMsg.includes("failed for listen port") ||
|
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") ||
|
const isRemoteHostClosure = errorMsg.includes("closed by remote host") ||
|
||||||
errorMsg.includes("connection reset by peer") ||
|
errorMsg.includes("connection reset by peer") ||
|
||||||
@@ -923,15 +1029,79 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
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;
|
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = 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 {
|
} else {
|
||||||
connOptions.password = tunnelConfig.sourcePassword;
|
connOptions.password = tunnelConfig.sourcePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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);
|
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
|
// Express API endpoints
|
||||||
@@ -959,6 +1129,7 @@ app.post('/connect', (req, res) => {
|
|||||||
|
|
||||||
const tunnelName = tunnelConfig.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);
|
||||||
|
|||||||
Reference in New Issue
Block a user