diff --git a/package-lock.json b/package-lock.json index 2973ba46..8c64a2de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "react-xtermjs": "^1.0.10", "recharts": "^3.2.1", "remark-gfm": "^4.0.1", + "socks": "^2.8.7", "sonner": "^2.0.7", "speakeasy": "^2.0.0", "ssh2": "^1.16.0", @@ -10624,7 +10625,6 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -15614,7 +15614,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -15625,7 +15624,6 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, "license": "MIT", "dependencies": { "ip-address": "^10.0.1", diff --git a/package.json b/package.json index d31836ff..d39360eb 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "react-xtermjs": "^1.0.10", "recharts": "^3.2.1", "remark-gfm": "^4.0.1", + "socks": "^2.8.7", "sonner": "^2.0.7", "speakeasy": "^2.0.0", "ssh2": "^1.16.0", diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 7eaab14c..dff125c8 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -210,6 +210,11 @@ async function initializeCompleteDatabase(): Promise { stats_config TEXT, docker_config TEXT, terminal_config TEXT, + use_socks5 INTEGER, + socks5_host TEXT, + socks5_port INTEGER, + socks5_username TEXT, + socks5_password TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE @@ -570,6 +575,14 @@ const migrateSchema = () => { ); addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); + // SOCKS5 Proxy columns + addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER"); + addColumnIfNotExists("ssh_data", "socks5_host", "TEXT"); + addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER"); + addColumnIfNotExists("ssh_data", "socks5_username", "TEXT"); + addColumnIfNotExists("ssh_data", "socks5_password", "TEXT"); + addColumnIfNotExists("ssh_data", "socks5_proxy_chain", "TEXT"); + addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 87a03881..39c091e6 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -93,6 +93,14 @@ export const sshData = sqliteTable("ssh_data", { statsConfig: text("stats_config"), terminalConfig: text("terminal_config"), quickActions: text("quick_actions"), + + useSocks5: integer("use_socks5", { mode: "boolean" }), + socks5Host: text("socks5_host"), + socks5Port: integer("socks5_port"), + socks5Username: text("socks5_username"), + socks5Password: text("socks5_password"), + socks5ProxyChain: text("socks5_proxy_chain"), // JSON array for proxy chains + createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index a090b267..c3e5510c 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -245,7 +245,20 @@ router.post( statsConfig, terminalConfig, forceKeyboardInteractive, + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain, } = hostData; + + console.log("POST /db/ssh - Received SOCKS5 data:", { + useSocks5, + socks5Host, + socks5ProxyChain, + }); + if ( !isNonEmptyString(userId) || !isNonEmptyString(ip) || @@ -288,6 +301,12 @@ router.post( statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", + useSocks5: useSocks5 ? 1 : 0, + socks5Host: socks5Host || null, + socks5Port: socks5Port || null, + socks5Username: socks5Username || null, + socks5Password: socks5Password || null, + socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null, }; if (effectiveAuthType === "password") { @@ -470,6 +489,12 @@ router.put( statsConfig, terminalConfig, forceKeyboardInteractive, + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain, } = hostData; if ( !isNonEmptyString(userId) || @@ -514,6 +539,12 @@ router.put( statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", + useSocks5: useSocks5 ? 1 : 0, + socks5Host: socks5Host || null, + socks5Port: socks5Port || null, + socks5Username: socks5Username || null, + socks5Password: socks5Password || null, + socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null, }; if (effectiveAuthType === "password") { @@ -792,6 +823,9 @@ router.get( ? JSON.parse(row.terminalConfig as string) : undefined, forceKeyboardInteractive: row.forceKeyboardInteractive === "true", + socks5ProxyChain: row.socks5ProxyChain + ? JSON.parse(row.socks5ProxyChain as string) + : [], // Add shared access metadata isShared: !!row.isShared, @@ -872,6 +906,9 @@ router.get( ? JSON.parse(host.terminalConfig) : undefined, forceKeyboardInteractive: host.forceKeyboardInteractive === "true", + socks5ProxyChain: host.socks5ProxyChain + ? JSON.parse(host.socks5ProxyChain) + : [], }; res.json((await resolveHostCredentials(result)) || result); @@ -943,6 +980,9 @@ router.get( tunnelConnections: resolvedHost.tunnelConnections ? JSON.parse(resolvedHost.tunnelConnections as string) : [], + socks5ProxyChain: resolvedHost.socks5ProxyChain + ? JSON.parse(resolvedHost.socks5ProxyChain as string) + : [], }; sshLogger.success("Host exported with decrypted credentials", { diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index b6344b68..0b52f844 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -10,6 +10,7 @@ import { fileLogger, sshLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../types/index.js"; +import { createSocks5Connection } from "../utils/socks5-helper.js"; function isExecutableFile(permissions: string, fileName: string): boolean { const hasExecutePermission = @@ -356,6 +357,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { userProvidedPassword, forceKeyboardInteractive, jumpHosts, + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain, } = req.body; const userId = (req as AuthenticatedRequest).userId; @@ -808,6 +815,83 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }, ); + fileLogger.info("SFTP connection request received", { + operation: "sftp_connect_request", + sessionId, + hostId, + ip, + port, + useSocks5, + socks5Host, + socks5Port, + hasSocks5ProxyChain: !!(socks5ProxyChain && (socks5ProxyChain as any).length > 0), + proxyChainLength: socks5ProxyChain ? (socks5ProxyChain as any).length : 0, + }); + + // Check if SOCKS5 proxy is enabled (either single proxy or chain) + if (useSocks5 && (socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0))) { + fileLogger.info("SOCKS5 enabled for SFTP, creating connection", { + operation: "sftp_socks5_enabled", + sessionId, + socks5Host, + socks5Port, + hasChain: !!(socks5ProxyChain && (socks5ProxyChain as any).length > 0), + }); + + try { + const socks5Socket = await createSocks5Connection( + ip, + port, + { + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain: socks5ProxyChain as any, + }, + ); + + if (socks5Socket) { + fileLogger.info("SOCKS5 socket created for SFTP", { + operation: "sftp_socks5_socket_ready", + sessionId, + }); + config.sock = socks5Socket; + client.connect(config); + return; + } else { + fileLogger.error("SOCKS5 socket is null for SFTP", undefined, { + operation: "sftp_socks5_socket_null", + sessionId, + }); + } + } catch (socks5Error) { + fileLogger.error("SOCKS5 connection failed", socks5Error, { + operation: "socks5_connect", + sessionId, + hostId, + proxyHost: socks5Host, + proxyPort: socks5Port || 1080, + }); + return res.status(500).json({ + error: + "SOCKS5 proxy connection failed: " + + (socks5Error instanceof Error + ? socks5Error.message + : "Unknown error"), + }); + } + } else { + fileLogger.info("SOCKS5 NOT enabled for SFTP connection", { + operation: "sftp_no_socks5", + sessionId, + useSocks5, + socks5Host, + hasChain: !!(socks5ProxyChain && (socks5ProxyChain as any).length > 0), + }); + } + if (jumpHosts && jumpHosts.length > 0 && userId) { try { const jumpClient = await createJumpHostChain(jumpHosts, userId); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index b4c958c9..a2386e23 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -9,7 +9,7 @@ import { eq, and } from "drizzle-orm"; import { statsLogger, sshLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; -import type { AuthenticatedRequest } from "../../types/index.js"; +import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js"; import { collectCpuMetrics } from "./widgets/cpu-collector.js"; import { collectMemoryMetrics } from "./widgets/memory-collector.js"; import { collectDiskMetrics } from "./widgets/disk-collector.js"; @@ -18,6 +18,7 @@ import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; import { collectProcessesMetrics } from "./widgets/processes-collector.js"; import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; +import { createSocks5Connection } from "../utils/socks5-helper.js"; async function resolveJumpHost( hostId: number, @@ -209,21 +210,44 @@ class SSHConnectionPool { } private getHostKey(host: SSHHostWithCredentials): string { - return `${host.ip}:${host.port}:${host.username}`; + // Include SOCKS5 settings in the key to ensure separate connection pools + // for direct connections vs SOCKS5 connections + const socks5Key = host.useSocks5 + ? `:socks5:${host.socks5Host}:${host.socks5Port}:${JSON.stringify(host.socks5ProxyChain || [])}` + : ""; + return `${host.ip}:${host.port}:${host.username}${socks5Key}`; } async getConnection(host: SSHHostWithCredentials): Promise { const hostKey = this.getHostKey(host); const connections = this.connections.get(hostKey) || []; + statsLogger.info("Getting connection from pool", { + operation: "get_connection_from_pool", + hostKey: hostKey, + availableConnections: connections.length, + useSocks5: host.useSocks5, + socks5Host: host.socks5Host, + hasSocks5ProxyChain: !!(host.socks5ProxyChain && host.socks5ProxyChain.length > 0), + hostId: host.id, + }); + const available = connections.find((conn) => !conn.inUse); if (available) { + statsLogger.info("Reusing existing connection from pool", { + operation: "reuse_connection", + hostKey, + }); available.inUse = true; available.lastUsed = Date.now(); return available.client; } if (connections.length < this.maxConnectionsPerHost) { + statsLogger.info("Creating new connection for pool", { + operation: "create_new_connection", + hostKey, + }); const client = await this.createConnection(host); const pooled: PooledConnection = { client, @@ -311,6 +335,68 @@ class SSHConnectionPool { try { const config = buildSshConfig(host); + // Check if SOCKS5 proxy is enabled (either single proxy or chain) + if ( + host.useSocks5 && + (host.socks5Host || (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) + ) { + statsLogger.info("Using SOCKS5 proxy for connection", { + operation: "socks5_enabled", + hostIp: host.ip, + hostPort: host.port, + socks5Host: host.socks5Host, + socks5Port: host.socks5Port, + hasChain: !!(host.socks5ProxyChain && host.socks5ProxyChain.length > 0), + chainLength: host.socks5ProxyChain?.length || 0, + }); + + try { + const socks5Socket = await createSocks5Connection( + host.ip, + host.port, + { + useSocks5: host.useSocks5, + socks5Host: host.socks5Host, + socks5Port: host.socks5Port, + socks5Username: host.socks5Username, + socks5Password: host.socks5Password, + socks5ProxyChain: host.socks5ProxyChain, + }, + ); + + if (socks5Socket) { + statsLogger.info("SOCKS5 socket created successfully", { + operation: "socks5_socket_ready", + hostIp: host.ip, + }); + config.sock = socks5Socket; + client.connect(config); + return; + } else { + statsLogger.error("SOCKS5 socket is null", undefined, { + operation: "socks5_socket_null", + hostIp: host.ip, + }); + } + } catch (socks5Error) { + clearTimeout(timeout); + statsLogger.error("SOCKS5 connection error", socks5Error, { + operation: "socks5_connection_error", + hostIp: host.ip, + errorMessage: socks5Error instanceof Error ? socks5Error.message : "Unknown", + }); + reject( + new Error( + "SOCKS5 proxy connection failed: " + + (socks5Error instanceof Error + ? socks5Error.message + : "Unknown error"), + ), + ); + return; + } + } + if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) { const jumpClient = await createJumpHostChain( host.jumpHosts, @@ -364,6 +450,29 @@ class SSHConnectionPool { } } + clearHostConnections(host: SSHHostWithCredentials): void { + const hostKey = this.getHostKey(host); + const connections = this.connections.get(hostKey) || []; + + statsLogger.info("Clearing all connections for host", { + operation: "clear_host_connections", + hostKey, + connectionCount: connections.length, + }); + + for (const conn of connections) { + try { + conn.client.end(); + } catch (error) { + statsLogger.error("Error closing connection during cleanup", error, { + operation: "clear_connection_error", + }); + } + } + + this.connections.delete(hostKey); + } + private cleanup(): void { const now = Date.now(); const maxAge = 10 * 60 * 1000; @@ -387,6 +496,27 @@ class SSHConnectionPool { } } + clearAllConnections(): void { + statsLogger.info("Clearing ALL connections from pool", { + operation: "clear_all_connections", + totalHosts: this.connections.size, + }); + + for (const [hostKey, connections] of this.connections.entries()) { + for (const conn of connections) { + try { + conn.client.end(); + } catch (error) { + statsLogger.error("Error closing connection during full cleanup", error, { + operation: "clear_all_error", + hostKey, + }); + } + } + } + this.connections.clear(); + } + destroy(): void { clearInterval(this.cleanupInterval); for (const connections of this.connections.values()) { @@ -604,6 +734,14 @@ interface SSHHostWithCredentials { createdAt: string; updatedAt: string; userId: string; + + // SOCKS5 Proxy configuration + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: ProxyNode[]; } type StatusEntry = { @@ -742,33 +880,51 @@ class PollingManager { } private async pollHostStatus(host: SSHHostWithCredentials): Promise { + // Refresh host data from database to get latest settings + const refreshedHost = await fetchHostById(host.id, host.userId); + if (!refreshedHost) { + statsLogger.warn("Host not found during status polling", { + operation: "poll_host_status", + hostId: host.id, + }); + return; + } + try { - const isOnline = await tcpPing(host.ip, host.port, 5000); + const isOnline = await tcpPing(refreshedHost.ip, refreshedHost.port, 5000); const statusEntry: StatusEntry = { status: isOnline ? "online" : "offline", lastChecked: new Date().toISOString(), }; - this.statusStore.set(host.id, statusEntry); + this.statusStore.set(refreshedHost.id, statusEntry); } catch (error) { const statusEntry: StatusEntry = { status: "offline", lastChecked: new Date().toISOString(), }; - this.statusStore.set(host.id, statusEntry); + this.statusStore.set(refreshedHost.id, statusEntry); } } private async pollHostMetrics(host: SSHHostWithCredentials): Promise { - const config = this.pollingConfigs.get(host.id); + // Refresh host data from database to get latest SOCKS5 and other settings + const refreshedHost = await fetchHostById(host.id, host.userId); + if (!refreshedHost) { + statsLogger.warn("Host not found during metrics polling", { + operation: "poll_host_metrics", + hostId: host.id, + }); + return; + } + + const config = this.pollingConfigs.get(refreshedHost.id); if (!config || !config.statsConfig.metricsEnabled) { return; } - const currentHost = config.host; - try { - const metrics = await collectMetrics(currentHost); - this.metricsStore.set(currentHost.id, { + const metrics = await collectMetrics(refreshedHost); + this.metricsStore.set(refreshedHost.id, { data: metrics, timestamp: Date.now(), }); @@ -776,12 +932,12 @@ class PollingManager { const errorMessage = error instanceof Error ? error.message : String(error); - const latestConfig = this.pollingConfigs.get(currentHost.id); + const latestConfig = this.pollingConfigs.get(refreshedHost.id); if (latestConfig && latestConfig.statsConfig.metricsEnabled) { statsLogger.warn("Failed to collect metrics for host", { operation: "metrics_poll_failed", - hostId: currentHost.id, - hostName: currentHost.name, + hostId: refreshedHost.id, + hostName: refreshedHost.name, error: errorMessage, }); } @@ -1007,6 +1163,15 @@ async function resolveHostCredentials( createdAt: host.createdAt, updatedAt: host.updatedAt, userId: host.userId, + // SOCKS5 proxy settings + useSocks5: !!host.useSocks5, + socks5Host: host.socks5Host || undefined, + socks5Port: host.socks5Port || undefined, + socks5Username: host.socks5Username || undefined, + socks5Password: host.socks5Password || undefined, + socks5ProxyChain: host.socks5ProxyChain + ? JSON.parse(host.socks5ProxyChain as string) + : undefined, }; if (host.credentialId) { @@ -1057,6 +1222,16 @@ async function resolveHostCredentials( addLegacyCredentials(baseHost, host); } + statsLogger.info("Resolved host credentials with SOCKS5 settings", { + operation: "resolve_host", + hostId: host.id as number, + useSocks5: baseHost.useSocks5, + socks5Host: baseHost.socks5Host, + socks5Port: baseHost.socks5Port, + hasSocks5ProxyChain: !!(baseHost.socks5ProxyChain && (baseHost.socks5ProxyChain as any[]).length > 0), + proxyChainLength: baseHost.socks5ProxyChain ? (baseHost.socks5ProxyChain as any[]).length : 0, + }); + return baseHost as unknown as SSHHostWithCredentials; } catch (error) { statsLogger.error( @@ -1194,6 +1369,7 @@ async function withSshConnection( fn: (client: Client) => Promise, ): Promise { const client = await connectionPool.getConnection(host); + try { const result = await fn(client); return result; @@ -1402,6 +1578,20 @@ app.get("/status/:id", validateHostId, async (req, res) => { res.json(statusEntry); }); +app.post("/clear-connections", async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + + connectionPool.clearAllConnections(); + res.json({ message: "All SSH connections cleared" }); +}); + app.post("/refresh", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; @@ -1412,6 +1602,9 @@ app.post("/refresh", async (req, res) => { }); } + // Clear all connections to ensure fresh connections with updated settings + connectionPool.clearAllConnections(); + await pollingManager.refreshHostPolling(userId); res.json({ message: "Polling refreshed" }); }); @@ -1434,6 +1627,9 @@ app.post("/host-updated", async (req, res) => { try { const host = await fetchHostById(hostId, userId); if (host) { + // Clear existing connections for this host to ensure new settings (like SOCKS5) are used + connectionPool.clearHostConnections(host); + await pollingManager.startPollingForHost(host); res.json({ message: "Host polling started" }); } else { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 9b863824..b41ebab3 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -14,6 +14,7 @@ import { sshLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import { UserCrypto } from "../utils/user-crypto.js"; +import { createSocks5Connection } from "../utils/socks5-helper.js"; interface ConnectToHostData { cols: number; @@ -32,6 +33,12 @@ interface ConnectToHostData { userId?: string; forceKeyboardInteractive?: boolean; jumpHosts?: Array<{ hostId: number }>; + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: unknown; }; initialPath?: string; executeCommand?: string; @@ -1180,6 +1187,53 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } + // Check if SOCKS5 proxy is enabled (either single proxy or chain) + if ( + hostConfig.useSocks5 && + (hostConfig.socks5Host || + (hostConfig.socks5ProxyChain && (hostConfig.socks5ProxyChain as any).length > 0)) + ) { + try { + const socks5Socket = await createSocks5Connection( + ip, + port, + { + useSocks5: hostConfig.useSocks5, + socks5Host: hostConfig.socks5Host, + socks5Port: hostConfig.socks5Port, + socks5Username: hostConfig.socks5Username, + socks5Password: hostConfig.socks5Password, + socks5ProxyChain: hostConfig.socks5ProxyChain as any, + }, + ); + + if (socks5Socket) { + connectConfig.sock = socks5Socket; + sshConn.connect(connectConfig); + return; + } + } catch (socks5Error) { + sshLogger.error("SOCKS5 connection failed", socks5Error, { + operation: "socks5_connect", + hostId: id, + proxyHost: hostConfig.socks5Host, + proxyPort: hostConfig.socks5Port || 1080, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "SOCKS5 proxy connection failed: " + + (socks5Error instanceof Error + ? socks5Error.message + : "Unknown error"), + }), + ); + cleanupSSH(connectionTimeout); + return; + } + } + if ( hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0 && diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 4365abb8..eb68577f 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -19,6 +19,7 @@ import { tunnelLogger, sshLogger } from "../utils/logger.js"; import { SystemCrypto } from "../utils/system-crypto.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { DataCrypto } from "../utils/data-crypto.js"; +import { createSocks5Connection } from "../utils/socks5-helper.js"; const app = express(); app.use( @@ -1016,6 +1017,51 @@ async function connectSSHTunnel( }); } + // Check if SOCKS5 proxy is enabled (either single proxy or chain) + if ( + tunnelConfig.useSocks5 && + (tunnelConfig.socks5Host || + (tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0)) + ) { + try { + const socks5Socket = await createSocks5Connection( + tunnelConfig.sourceIP, + tunnelConfig.sourceSSHPort, + { + useSocks5: tunnelConfig.useSocks5, + socks5Host: tunnelConfig.socks5Host, + socks5Port: tunnelConfig.socks5Port, + socks5Username: tunnelConfig.socks5Username, + socks5Password: tunnelConfig.socks5Password, + socks5ProxyChain: tunnelConfig.socks5ProxyChain, + }, + ); + + if (socks5Socket) { + connOptions.sock = socks5Socket; + conn.connect(connOptions); + return; + } + } catch (socks5Error) { + tunnelLogger.error("SOCKS5 connection failed for tunnel", socks5Error, { + operation: "socks5_connect", + tunnelName, + proxyHost: tunnelConfig.socks5Host, + proxyPort: tunnelConfig.socks5Port || 1080, + }); + broadcastTunnelStatus(tunnelName, { + connected: false, + status: CONNECTION_STATES.FAILED, + reason: + "SOCKS5 proxy connection failed: " + + (socks5Error instanceof Error + ? socks5Error.message + : "Unknown error"), + }); + return; + } + } + conn.connect(connOptions); } @@ -1248,7 +1294,57 @@ async function killRemoteTunnelByMarker( callback(err); }); - conn.connect(connOptions); + // Check if SOCKS5 proxy is enabled (either single proxy or chain) + if ( + tunnelConfig.useSocks5 && + (tunnelConfig.socks5Host || + (tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0)) + ) { + (async () => { + try { + const socks5Socket = await createSocks5Connection( + tunnelConfig.sourceIP, + tunnelConfig.sourceSSHPort, + { + useSocks5: tunnelConfig.useSocks5, + socks5Host: tunnelConfig.socks5Host, + socks5Port: tunnelConfig.socks5Port, + socks5Username: tunnelConfig.socks5Username, + socks5Password: tunnelConfig.socks5Password, + socks5ProxyChain: tunnelConfig.socks5ProxyChain, + }, + ); + + if (socks5Socket) { + connOptions.sock = socks5Socket; + conn.connect(connOptions); + } else { + callback(new Error("Failed to create SOCKS5 connection")); + } + } catch (socks5Error) { + tunnelLogger.error( + "SOCKS5 connection failed for killing tunnel", + socks5Error, + { + operation: "socks5_connect_kill", + tunnelName, + proxyHost: tunnelConfig.socks5Host, + proxyPort: tunnelConfig.socks5Port || 1080, + }, + ); + callback( + new Error( + "SOCKS5 proxy connection failed: " + + (socks5Error instanceof Error + ? socks5Error.message + : "Unknown error"), + ), + ); + } + })(); + } else { + conn.connect(connOptions); + } } app.get("/ssh/tunnel/status", (req, res) => { @@ -1453,6 +1549,11 @@ async function initializeAutoStartTunnels(): Promise { retryInterval: tunnelConnection.retryInterval * 1000, autoStart: tunnelConnection.autoStart, isPinned: host.pin, + useSocks5: host.useSocks5, + socks5Host: host.socks5Host, + socks5Port: host.socks5Port, + socks5Username: host.socks5Username, + socks5Password: host.socks5Password, }; autoStartTunnels.push(tunnelConfig); diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index 6fbd7a63..57d2803e 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -2,7 +2,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; import { DataCrypto } from "./data-crypto.js"; import type { SQLiteTable } from "drizzle-orm/sqlite-core"; -type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity"; +type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity" | "socks5_proxy_presets"; class SimpleDBOps { static async insert>( diff --git a/src/backend/utils/socks5-helper.ts b/src/backend/utils/socks5-helper.ts new file mode 100644 index 00000000..8dd9d96c --- /dev/null +++ b/src/backend/utils/socks5-helper.ts @@ -0,0 +1,160 @@ +import { SocksClient } from "socks"; +import type { SocksClientOptions } from "socks"; +import net from "net"; +import { sshLogger } from "./logger.js"; +import type { ProxyNode } from "../../types/index.js"; + +export interface SOCKS5Config { + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: ProxyNode[]; +} + +/** + * Creates a SOCKS5 connection through a single proxy or a chain of proxies + * @param targetHost - Target SSH server hostname/IP + * @param targetPort - Target SSH server port + * @param socks5Config - SOCKS5 proxy configuration + * @returns Promise with connected socket or null if SOCKS5 is not enabled + */ +export async function createSocks5Connection( + targetHost: string, + targetPort: number, + socks5Config: SOCKS5Config, +): Promise { + // If SOCKS5 is not enabled, return null + if (!socks5Config.useSocks5) { + return null; + } + + // If proxy chain is provided, use chain connection + if (socks5Config.socks5ProxyChain && socks5Config.socks5ProxyChain.length > 0) { + return createProxyChainConnection(targetHost, targetPort, socks5Config.socks5ProxyChain); + } + + // If single proxy is configured, use single proxy connection + if (socks5Config.socks5Host) { + return createSingleProxyConnection(targetHost, targetPort, socks5Config); + } + + // No proxy configured + return null; +} + +/** + * Creates a connection through a single SOCKS proxy + */ +async function createSingleProxyConnection( + targetHost: string, + targetPort: number, + socks5Config: SOCKS5Config, +): Promise { + const socksOptions: SocksClientOptions = { + proxy: { + host: socks5Config.socks5Host!, + port: socks5Config.socks5Port || 1080, + type: 5, + userId: socks5Config.socks5Username, + password: socks5Config.socks5Password, + }, + command: "connect", + destination: { + host: targetHost, + port: targetPort, + }, + }; + + sshLogger.info("Creating SOCKS5 connection", { + operation: "socks5_connect", + proxyHost: socks5Config.socks5Host, + proxyPort: socks5Config.socks5Port || 1080, + targetHost, + targetPort, + hasAuth: !!(socks5Config.socks5Username && socks5Config.socks5Password), + }); + + try { + const info = await SocksClient.createConnection(socksOptions); + + sshLogger.info("SOCKS5 connection established", { + operation: "socks5_connected", + targetHost, + targetPort, + }); + + return info.socket; + } catch (error) { + sshLogger.error("SOCKS5 connection failed", error, { + operation: "socks5_connect_failed", + proxyHost: socks5Config.socks5Host, + proxyPort: socks5Config.socks5Port || 1080, + targetHost, + targetPort, + errorMessage: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } +} + +/** + * Creates a connection through a chain of SOCKS proxies + * Each proxy in the chain connects through the previous one + */ +async function createProxyChainConnection( + targetHost: string, + targetPort: number, + proxyChain: ProxyNode[], +): Promise { + if (proxyChain.length === 0) { + throw new Error("Proxy chain is empty"); + } + + const chainPath = proxyChain.map((p) => `${p.host}:${p.port}`).join(" → "); + sshLogger.info(`Creating SOCKS proxy chain: ${chainPath} → ${targetHost}:${targetPort}`, { + operation: "socks5_chain_connect", + chainLength: proxyChain.length, + targetHost, + targetPort, + proxies: proxyChain.map((p) => `${p.host}:${p.port}`), + }); + + try { + const info = await SocksClient.createConnectionChain({ + proxies: proxyChain.map((p) => ({ + host: p.host, + port: p.port, + type: p.type, + userId: p.username, + password: p.password, + timeout: 10000, // 10-second timeout for each hop + })), + command: "connect", + destination: { + host: targetHost, + port: targetPort, + }, + }); + + sshLogger.info(`✓ Proxy chain established: ${chainPath} → ${targetHost}:${targetPort}`, { + operation: "socks5_chain_connected", + chainLength: proxyChain.length, + targetHost, + targetPort, + fullPath: `${chainPath} → ${targetHost}:${targetPort}`, + }); + + return info.socket; + } catch (error) { + sshLogger.error("SOCKS proxy chain connection failed", error, { + operation: "socks5_chain_connect_failed", + chainLength: proxyChain.length, + targetHost, + targetPort, + errorMessage: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8ce7fa31..199fb9b0 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -640,6 +640,7 @@ "failedToLoadHosts": "Failed to load hosts", "retry": "Retry", "refresh": "Refresh", + "optional": "Optional", "hostsCount": "{{count}} hosts", "importJson": "Import JSON", "importing": "Importing...", @@ -889,6 +890,47 @@ "searchServers": "Search servers...", "noServerFound": "No server found", "jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server", + "socks5Proxy": "SOCKS5 Proxy", + "socks5Description": "Configure SOCKS5 proxy for SSH connection. All traffic will be routed through the specified proxy server.", + "enableSocks5": "Enable SOCKS5 Proxy", + "enableSocks5Description": "Use SOCKS5 proxy for this SSH connection", + "socks5Host": "Proxy Host", + "socks5Port": "Proxy Port", + "socks5Username": "Proxy Username", + "socks5Password": "Proxy Password", + "socks5UsernameOptional": "Optional: leave empty if proxy doesn't require authentication", + "socks5PasswordOptional": "Optional: leave empty if proxy doesn't require authentication", + "socks5ProxyChain": "Proxy Chain", + "socks5ProxyChainDescription": "Configure a chain of SOCKS proxies. Each proxy in the chain will connect through the previous one.", + "socks5ProxyMode": "Proxy Mode", + "socks5UseSingleProxy": "Use Single Proxy", + "socks5UseProxyChain": "Use Proxy Chain", + "socks5UsePreset": "Use Saved Preset", + "socks5SelectPreset": "Select Preset", + "socks5ManagePresets": "Manage Presets", + "socks5ProxyNode": "Proxy {{number}}", + "socks5AddProxy": "Add Proxy to Chain", + "socks5RemoveProxy": "Remove Proxy", + "socks5ProxyType": "Proxy Type", + "socks5SaveAsPreset": "Save as Preset", + "socks5SavePresetTitle": "Save Proxy Chain as Preset", + "socks5SavePresetDescription": "Save the current proxy chain configuration as a reusable preset", + "socks5PresetName": "Preset Name", + "socks5PresetDescription": "Description (optional)", + "socks5PresetCreated": "Proxy chain preset created", + "socks5PresetUpdated": "Proxy chain preset updated", + "socks5PresetDeleted": "Proxy chain preset deleted", + "socks5PresetSaved": "Preset \"{{name}}\" saved successfully", + "socks5PresetSaveError": "Failed to save preset", + "socks5PresetNameRequired": "Preset name is required", + "socks5EmptyChainError": "Cannot save an empty proxy chain", + "socks5ProxyChainEmpty": "Add at least one proxy to the chain", + "socks5HostDescription": "Hostname or IP address of the SOCKS proxy server", + "socks5PortDescription": "Port number of the SOCKS proxy server (default: 1080)", + "addProxyNode": "Add Proxy Node", + "noProxyNodes": "No proxy nodes configured. Click 'Add Proxy Node' to add one.", + "proxyNode": "Proxy Node", + "proxyType": "Proxy Type", "quickActions": "Quick Actions", "quickActionsDescription": "Quick actions allow you to create custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.", "quickActionsList": "Quick Actions List", @@ -1600,7 +1642,12 @@ "folderName": "Enter folder name", "fullPath": "Enter full path to item", "currentPath": "Enter current path to item", - "newName": "Enter new name" + "newName": "Enter new name", + "socks5Host": "127.0.0.1", + "socks5Username": "proxy username", + "socks5Password": "proxy password", + "socks5PresetName": "e.g., Work VPN Chain", + "socks5PresetDescription": "e.g., Proxy chain for accessing work servers" }, "leftSidebar": { "failedToLoadHosts": "Failed to load hosts", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 62e24122..6507f8e8 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -874,6 +874,48 @@ "searchServers": "Поиск серверов...", "noServerFound": "Сервер не найден", "jumpHostsOrder": "Подключения будут выполнены в порядке: Промежуточный хост 1 → Промежуточный хост 2 → ... → Целевой сервер", + "socks5Proxy": "SOCKS5 Прокси", + "socks5Description": "Настройте SOCKS5 прокси для SSH подключения. Весь трафик будет направлен через указанный прокси-сервер.", + "enableSocks5": "Включить SOCKS5 Прокси", + "enableSocks5Description": "Использовать SOCKS5 прокси для этого SSH подключения", + "socks5Host": "Хост прокси", + "socks5Port": "Порт прокси", + "socks5Username": "Имя пользователя прокси", + "socks5Password": "Пароль прокси", + "socks5UsernameOptional": "Необязательно: оставьте пустым, если прокси не требует аутентификации", + "socks5PasswordOptional": "Необязательно: оставьте пустым, если прокси не требует аутентификации", + "socks5ProxyChain": "Цепочка Прокси", + "socks5ProxyChainDescription": "Настройте цепочку SOCKS прокси. Каждый прокси в цепочке будет подключаться через предыдущий.", + "socks5ProxyMode": "Режим Прокси", + "socks5UseSingleProxy": "Использовать Один Прокси", + "socks5UseProxyChain": "Использовать Цепочку Прокси", + "socks5UsePreset": "Использовать Сохраненный Пресет", + "socks5SelectPreset": "Выбрать Пресет", + "socks5ManagePresets": "Управление Пресетами", + "socks5ProxyNode": "Прокси {{number}}", + "socks5AddProxy": "Добавить Прокси в Цепочку", + "socks5RemoveProxy": "Удалить Прокси", + "socks5ProxyType": "Тип Прокси", + "socks5SaveAsPreset": "Сохранить как Пресет", + "socks5SavePresetTitle": "Сохранить Цепочку Прокси как Пресет", + "socks5SavePresetDescription": "Сохраните текущую конфигурацию цепочки прокси как переиспользуемый пресет", + "socks5PresetName": "Название Пресета", + "socks5PresetDescription": "Описание (необязательно)", + "socks5PresetCreated": "Пресет цепочки прокси создан", + "socks5PresetUpdated": "Пресет цепочки прокси обновлен", + "socks5PresetDeleted": "Пресет цепочки прокси удален", + "socks5PresetSaved": "Пресет \"{{name}}\" успешно сохранен", + "socks5PresetSaveError": "Не удалось сохранить пресет", + "socks5PresetNameRequired": "Необходимо указать название пресета", + "socks5EmptyChainError": "Невозможно сохранить пустую цепочку прокси", + "socks5ProxyChainEmpty": "Добавьте хотя бы один прокси в цепочку", + "socks5HostDescription": "Имя хоста или IP-адрес SOCKS прокси сервера", + "socks5PortDescription": "Номер порта SOCKS прокси сервера (по умолчанию: 1080)", + "addProxyNode": "Добавить узел прокси", + "noProxyNodes": "Узлы прокси не настроены. Нажмите 'Добавить узел прокси' чтобы добавить.", + "proxyNode": "Узел прокси", + "proxyType": "Тип прокси", + "advancedAuthSettings": "Расширенные настройки аутентификации" "advancedAuthSettings": "Расширенные настройки аутентификации", "addQuickAction": "Добавить Quick Action", "allHostsInFolderDeleted": "{{count}} хостов успешно удалены из папки \"{{folder}}\"", @@ -1594,7 +1636,12 @@ "folderName": "Введите имя папки", "fullPath": "Введите полный путь к элементу", "currentPath": "Введите текущий путь к элементу", - "newName": "Введите новое имя" + "newName": "Введите новое имя", + "socks5Host": "127.0.0.1", + "socks5Username": "имя пользователя прокси", + "socks5Password": "пароль прокси", + "socks5PresetName": "например, Рабочая VPN Цепочка", + "socks5PresetDescription": "например, Цепочка прокси для доступа к рабочим серверам" }, "leftSidebar": { "failedToLoadHosts": "Не удалось загрузить хосты", diff --git a/src/types/index.ts b/src/types/index.ts index 1f022e21..7eae315a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -45,8 +45,16 @@ export interface SSHHost { tunnelConnections: TunnelConnection[]; jumpHosts?: JumpHost[]; quickActions?: QuickAction[]; - statsConfig?: string; + statsConfig?: string | Record; terminalConfig?: TerminalConfig; + + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: ProxyNode[]; + createdAt: string; updatedAt: string; @@ -65,6 +73,14 @@ export interface QuickActionData { snippetId: number; } +export interface ProxyNode { + host: string; + port: number; + type: 4 | 5; // SOCKS4 or SOCKS5 + username?: string; + password?: string; +} + export interface SSHHostData { name?: string; ip: string; @@ -91,6 +107,14 @@ export interface SSHHostData { quickActions?: QuickActionData[]; statsConfig?: string | Record; terminalConfig?: TerminalConfig; + + // SOCKS5 Proxy configuration + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: ProxyNode[]; } export interface SSHFolder { @@ -211,6 +235,14 @@ export interface TunnelConfig { retryInterval: number; autoStart: boolean; isPinned: boolean; + + // SOCKS5 Proxy configuration + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: ProxyNode[]; } export interface TunnelStatus { diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 214ee4ac..d45dde8e 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -336,6 +336,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { credentialId: currentHost.credentialId, userId: currentHost.userId, forceKeyboardInteractive: currentHost.forceKeyboardInteractive, + useSocks5: currentHost.useSocks5, + socks5Host: currentHost.socks5Host, + socks5Port: currentHost.socks5Port, + socks5Username: currentHost.socks5Username, + socks5Password: currentHost.socks5Password, + socks5ProxyChain: currentHost.socks5ProxyChain, }); if (result?.requires_totp) { @@ -760,14 +766,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { if (!status.connected) { const result = await connectSSH(currentSessionId, { hostId: currentHost.id, - host: currentHost.ip, + ip: currentHost.ip, port: currentHost.port, username: currentHost.username, authType: currentHost.authType, password: currentHost.password, - key: currentHost.key, + sshKey: currentHost.key, keyPassword: currentHost.keyPassword, credentialId: currentHost.credentialId, + useSocks5: currentHost.useSocks5, + socks5Host: currentHost.socks5Host, + socks5Port: currentHost.socks5Port, + socks5Username: currentHost.socks5Username, + socks5Password: currentHost.socks5Password, + socks5ProxyChain: currentHost.socks5ProxyChain, }); if (!result.success) { @@ -1313,6 +1325,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { authType: currentHost.authType, credentialId: currentHost.credentialId, userId: currentHost.userId, + useSocks5: currentHost.useSocks5, + socks5Host: currentHost.socks5Host, + socks5Port: currentHost.socks5Port, + socks5Username: currentHost.socks5Username, + socks5Password: currentHost.socks5Password, + socks5ProxyChain: currentHost.socks5ProxyChain, }); } } catch (error) { @@ -1464,7 +1482,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { authType: credentials.password ? "password" : "key", credentialId: currentHost.credentialId, userId: currentHost.userId, - userProvidedPassword: true, + useSocks5: currentHost.useSocks5, + socks5Host: currentHost.socks5Host, + socks5Port: currentHost.socks5Port, + socks5Username: currentHost.socks5Username, + socks5Password: currentHost.socks5Password, + socks5ProxyChain: currentHost.socks5ProxyChain, }); if (result?.requires_totp) { diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index 4ad0e5f5..9b27f47a 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -70,6 +70,14 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; import { TERMINAL_THEMES, TERMINAL_FONTS, @@ -79,8 +87,8 @@ import { DEFAULT_TERMINAL_CONFIG, } from "@/constants/terminal-themes"; import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx"; -import type { TerminalConfig } from "@/types"; -import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; +import type { TerminalConfig, SSHHost, Credential } from "@/types"; +import { Plus, X, Check, ChevronsUpDown, Save } from "lucide-react"; interface JumpHostItemProps { jumpHost: { hostId: number }; @@ -278,46 +286,6 @@ function QuickActionItem({ ); } -interface SSHHost { - id: number; - name: string; - ip: string; - port: number; - username: string; - folder: string; - tags: string[]; - pin: boolean; - authType: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - enableTerminal: boolean; - enableTunnel: boolean; - enableFileManager: boolean; - defaultPath: string; - tunnelConnections: Array<{ - sourcePort: number; - endpointPort: number; - endpointHost: string; - maxRetries: number; - retryInterval: number; - autoStart: boolean; - }>; - jumpHosts?: Array<{ - hostId: number; - }>; - quickActions?: Array<{ - name: string; - snippetId: number; - }>; - statsConfig?: StatsConfig; - terminalConfig?: TerminalConfig; - createdAt: string; - updatedAt: string; - credentialId?: number; -} - interface SSHManagerHostEditorProps { editingHost?: SSHHost | null; onFormSubmit?: (updatedHost?: SSHHost) => void; @@ -331,12 +299,11 @@ export function HostManagerEditor({ const [folders, setFolders] = useState([]); const [sshConfigurations, setSshConfigurations] = useState([]); const [hosts, setHosts] = useState([]); - const [credentials, setCredentials] = useState< - Array<{ id: number; username: string; authType: string }> - >([]); + const [credentials, setCredentials] = useState([]); const [snippets, setSnippets] = useState< Array<{ id: number; name: string; content: string }> >([]); + const [proxyMode, setProxyMode] = useState<"single" | "chain">("single"); const [authTab, setAuthTab] = useState< "password" | "key" | "credential" | "none" @@ -370,7 +337,7 @@ export function HostManagerEditor({ getSnippets(), ]); setHosts(hostsData); - setCredentials(credentialsData); + setCredentials(credentialsData as Credential[]); setSnippets(Array.isArray(snippetsData) ? snippetsData : []); const uniqueFolders = [ @@ -567,6 +534,22 @@ export function HostManagerEditor({ }), ) .default([]), + useSocks5: z.boolean().optional(), + socks5Host: z.string().optional(), + socks5Port: z.coerce.number().min(1).max(65535).optional(), + socks5Username: z.string().optional(), + socks5Password: z.string().optional(), + socks5ProxyChain: z + .array( + z.object({ + host: z.string().min(1), + port: z.number().min(1).max(65535), + type: z.union([z.literal(4), z.literal(5)]), + username: z.string().optional(), + password: z.string().optional(), + }), + ) + .optional(), enableDocker: z.boolean().default(false), }) .superRefine((data, ctx) => { @@ -604,11 +587,7 @@ export function HostManagerEditor({ }); } } else if (data.authType === "credential") { - if ( - !data.credentialId || - (typeof data.credentialId === "string" && - data.credentialId.trim() === "") - ) { + if (!data.credentialId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.credentialRequired"), @@ -660,6 +639,12 @@ export function HostManagerEditor({ statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, + useSocks5: false, + socks5Host: "", + socks5Port: 1080, + socks5Username: "", + socks5Password: "", + socks5ProxyChain: [], enableDocker: false, }, }); @@ -677,7 +662,7 @@ export function HostManagerEditor({ } } } - }, [authTab, credentials, form.getValues, form.setValue]); + }, [authTab, credentials, form]); useEffect(() => { if (editingHost) { @@ -701,13 +686,13 @@ export function HostManagerEditor({ : "none"; setAuthTab(defaultAuthType); - let parsedStatsConfig = DEFAULT_STATS_CONFIG; + let parsedStatsConfig: StatsConfig = DEFAULT_STATS_CONFIG; try { if (cleanedHost.statsConfig) { parsedStatsConfig = typeof cleanedHost.statsConfig === "string" ? JSON.parse(cleanedHost.statsConfig) - : cleanedHost.statsConfig; + : (cleanedHost.statsConfig as StatsConfig); } } catch (error) { console.error("Failed to parse statsConfig:", error); @@ -715,7 +700,7 @@ export function HostManagerEditor({ parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig }; - const formData = { + const formData: Partial = { name: cleanedHost.name || "", ip: cleanedHost.ip || "", port: cleanedHost.port || 22, @@ -724,7 +709,7 @@ export function HostManagerEditor({ tags: Array.isArray(cleanedHost.tags) ? cleanedHost.tags : [], pin: Boolean(cleanedHost.pin), authType: defaultAuthType as "password" | "key" | "credential" | "none", - credentialId: null, + credentialId: cleanedHost.credentialId, overrideCredentialUsername: Boolean( cleanedHost.overrideCredentialUsername, ), @@ -756,9 +741,27 @@ export function HostManagerEditor({ : [], }, forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), + useSocks5: Boolean(cleanedHost.useSocks5), + socks5Host: cleanedHost.socks5Host || "", + socks5Port: cleanedHost.socks5Port || 1080, + socks5Username: cleanedHost.socks5Username || "", + socks5Password: cleanedHost.socks5Password || "", + socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain) + ? cleanedHost.socks5ProxyChain + : [], enableDocker: Boolean(cleanedHost.enableDocker), }; + // Determine proxy mode based on existing data + if ( + Array.isArray(cleanedHost.socks5ProxyChain) && + cleanedHost.socks5ProxyChain.length > 0 + ) { + setProxyMode("chain"); + } else { + setProxyMode("single"); + } + if (defaultAuthType === "password") { formData.password = cleanedHost.password || ""; } else if (defaultAuthType === "key") { @@ -776,14 +779,13 @@ export function HostManagerEditor({ | "ssh-rsa-sha2-256" | "ssh-rsa-sha2-512") || "auto"; } else if (defaultAuthType === "credential") { - formData.credentialId = - cleanedHost.credentialId || "existing_credential"; + formData.credentialId = cleanedHost.credentialId; } - form.reset(formData); + form.reset(formData as FormData); } else { setAuthTab("password"); - const defaultFormData = { + const defaultFormData: Partial = { name: "", ip: "", port: 22, @@ -811,9 +813,9 @@ export function HostManagerEditor({ enableDocker: false, }; - form.reset(defaultFormData); + form.reset(defaultFormData as FormData); } - }, [editingHost?.id]); + }, [editingHost, form]); useEffect(() => { const focusTimer = setTimeout(() => { @@ -826,6 +828,8 @@ export function HostManagerEditor({ }, [editingHost]); const onSubmit = async (data: FormData) => { + await form.trigger(); + console.log("onSubmit called with data:", data); try { isSubmittingRef.current = true; setFormError(null); @@ -855,66 +859,45 @@ export function HostManagerEditor({ } } - const submitData: Record = { - name: data.name, - ip: data.ip, - port: data.port, - username: data.username, - folder: data.folder || "", - tags: data.tags || [], - pin: Boolean(data.pin), - authType: data.authType, - overrideCredentialUsername: Boolean(data.overrideCredentialUsername), - enableTerminal: Boolean(data.enableTerminal), - enableDocker: Boolean(data.enableDocker), - enableTunnel: Boolean(data.enableTunnel), - enableFileManager: Boolean(data.enableFileManager), - defaultPath: data.defaultPath || "/", - tunnelConnections: data.tunnelConnections || [], - jumpHosts: data.jumpHosts || [], - quickActions: data.quickActions || [], - statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG, - terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG, - forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive), + const submitData: Partial = { + ...data, }; + + if (proxyMode === "single") { + submitData.socks5ProxyChain = []; + } else if (proxyMode === "chain") { + submitData.socks5Host = ""; + submitData.socks5Port = 1080; + submitData.socks5Username = ""; + submitData.socks5Password = ""; + } - submitData.credentialId = null; - submitData.password = null; - submitData.key = null; - submitData.keyPassword = null; - submitData.keyType = null; + if (data.authType !== "credential") { + submitData.credentialId = undefined; + } + if (data.authType !== "password") { + submitData.password = undefined; + } + if (data.authType !== "key") { + submitData.key = undefined; + submitData.keyPassword = undefined; + submitData.keyType = undefined; + } - if (data.authType === "credential") { - if ( - data.credentialId === "existing_credential" && - editingHost && - editingHost.id - ) { - delete submitData.credentialId; - } else { - submitData.credentialId = data.credentialId; - } - } else if (data.authType === "password") { - submitData.password = data.password; - } else if (data.authType === "key") { + if (data.authType === "key") { if (data.key instanceof File) { - const keyContent = await data.key.text(); - submitData.key = keyContent; + submitData.key = await data.key.text(); } else if (data.key === "existing_key") { delete submitData.key; - } else { - submitData.key = data.key; } - submitData.keyPassword = data.keyPassword; - submitData.keyType = data.keyType; } let savedHost; if (editingHost && editingHost.id) { - savedHost = await updateSSHHost(editingHost.id, submitData); + savedHost = await updateSSHHost(editingHost.id, submitData as any); toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name })); } else { - savedHost = await createSSHHost(submitData); + savedHost = await createSSHHost(submitData as any); toast.success(t("hosts.hostAddedSuccessfully", { name: data.name })); } @@ -959,7 +942,9 @@ export function HostManagerEditor({ notifyHostCreatedOrUpdated(savedHost.id); } } catch (error) { - toast.error(t("hosts.failedToSaveHost")); + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage); console.error("Failed to save host:", error); } finally { isSubmittingRef.current = false; @@ -1499,7 +1484,8 @@ export function HostManagerEditor({ {field.value === "existing_key" @@ -1507,7 +1493,7 @@ export function HostManagerEditor({ : field.value ? editingHost ? t("hosts.updateKey") - : field.value.name + : (field.value as File).name : t("hosts.upload")} @@ -1546,8 +1532,6 @@ export function HostManagerEditor({ dropCursor: false, allowMultipleSelections: false, highlightSelectionMatches: false, - searchKeymap: false, - scrollPastEnd: false, }} extensions={[ EditorView.theme({ @@ -1796,159 +1780,405 @@ export function HostManagerEditor({ /> + + + + {t("hosts.socks5Proxy")} + + + + + {t("hosts.socks5Description")} + + + + ( + +
+ {t("hosts.enableSocks5")} + + {t("hosts.enableSocks5Description")} + +
+ + + +
+ )} + /> + + {form.watch("useSocks5") && ( +
+
+ + {t("hosts.socks5ProxyMode")} + +
+ + +
+
+ + {proxyMode === "single" && ( +
+ ( + + + {t("hosts.socks5Host")} + + + + + + {t("hosts.socks5HostDescription")} + + + )} + /> + + ( + + + {t("hosts.socks5Port")} + + + + field.onChange( + parseInt(e.target.value) || 1080, + ) + } + /> + + + {t("hosts.socks5PortDescription")} + + + )} + /> + + ( + + + {t("hosts.socks5Username")} ( + {t("hosts.optional")}) + + + + + + )} + /> + + ( + + + {t("hosts.socks5Password")} ( + {t("hosts.optional")}) + + + + + + )} + /> +
+ )} + + {proxyMode === "chain" && ( +
+
+ + {t("hosts.socks5ProxyChain")} + + +
+ + {(form.watch("socks5ProxyChain") || []) + .length === 0 && ( +
+ {t("hosts.noProxyNodes")} +
+ )} + + {(form.watch("socks5ProxyChain") || []).map( + (node: any, index: number) => ( +
+
+ + {t("hosts.proxyNode")} {index + 1} + + +
+ +
+
+ + {t("hosts.socks5Host")} + + { + const currentChain = + form.watch( + "socks5ProxyChain", + ) || []; + const newChain = [ + ...currentChain, + ]; + newChain[index] = { + ...newChain[index], + host: e.target.value, + }; + form.setValue( + "socks5ProxyChain", + newChain, + ); + }} + /> +
+ +
+ + {t("hosts.socks5Port")} + + { + const currentChain = + form.watch( + "socks5ProxyChain", + ) || []; + const newChain = [ + ...currentChain, + ]; + newChain[index] = { + ...newChain[index], + port: + parseInt(e.target.value) || + 1080, + }; + form.setValue( + "socks5ProxyChain", + newChain, + ); + }} + /> +
+
+ +
+ + {t("hosts.proxyType")} + + +
+ +
+
+ + {t("hosts.socks5Username")} ( + {t("hosts.optional")}) + + { + const currentChain = + form.watch( + "socks5ProxyChain", + ) || []; + const newChain = [ + ...currentChain, + ]; + newChain[index] = { + ...newChain[index], + username: e.target.value, + }; + form.setValue( + "socks5ProxyChain", + newChain, + ); + }} + /> +
+ +
+ + {t("hosts.socks5Password")} ( + {t("hosts.optional")}) + + { + const currentChain = + form.watch( + "socks5ProxyChain", + ) || []; + const newChain = [ + ...currentChain, + ]; + newChain[index] = { + ...newChain[index], + password: e.target.value, + }; + form.setValue( + "socks5ProxyChain", + newChain, + ); + }} + /> +
+
+
+ ), + )} +
+ )} +
+ )} +
+
- - ( - - {t("hosts.enableTerminal")} - - - - - {t("hosts.enableTerminalDesc")} - - - )} - /> - - - {t("hosts.terminalCustomizationNotice")} - - -

- {t("hosts.terminalCustomization")} -

- + + + {t("hosts.appearance")} -
- - -
- - ( - - {t("hosts.theme")} - - - {t("hosts.chooseColorTheme")} - - - )} - /> - - ( - - {t("hosts.fontFamily")} - - - {t("hosts.selectFontDesc")} - - - )} - /> - - ( - - - {t("hosts.fontSizeValue", { - value: field.value, - })} - - - - field.onChange(value) - } - /> - - - {t("hosts.adjustFontSize")} - - - )} - /> - { : null, terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), + useSocks5: Boolean(hostData.useSocks5), + socks5Host: hostData.socks5Host || null, + socks5Port: hostData.socks5Port || null, + socks5Username: hostData.socks5Username || null, + socks5Password: hostData.socks5Password || null, + socks5ProxyChain: hostData.socks5ProxyChain || null, }; if (!submitData.enableTunnel) { @@ -1003,6 +1009,12 @@ export async function updateSSHHost( : null, terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), + useSocks5: Boolean(hostData.useSocks5), + socks5Host: hostData.socks5Host || null, + socks5Port: hostData.socks5Port || null, + socks5Username: hostData.socks5Username || null, + socks5Password: hostData.socks5Password || null, + socks5ProxyChain: hostData.socks5ProxyChain || null, }; if (!submitData.enableTunnel) { @@ -1314,6 +1326,12 @@ export async function connectSSH( credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: unknown; }, ): Promise> { try {