SOCKS5 support (#452)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* SOCKS5 support

Adding single and chain socks5 proxy support

* fix: cleanup files

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
This commit was merged in pull request #452.
This commit is contained in:
Denis
2025-12-20 09:35:40 +07:00
committed by GitHub
parent 94651107c1
commit ab1c63a4f6
18 changed files with 1342 additions and 284 deletions

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -210,6 +210,11 @@ async function initializeCompleteDatabase(): Promise<void> {
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");

View File

@@ -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`),

View File

@@ -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", {

View File

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

View File

@@ -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<Client> {
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<void> {
// 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<void> {
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<T>(
fn: (client: Client) => Promise<T>,
): Promise<T> {
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 {

View File

@@ -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 &&

View File

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

View File

@@ -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<T extends Record<string, unknown>>(

View File

@@ -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<net.Socket | null> {
// 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<net.Socket> {
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<net.Socket> {
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;
}
}

View File

@@ -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",

View File

@@ -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": "Не удалось загрузить хосты",

View File

@@ -45,8 +45,16 @@ export interface SSHHost {
tunnelConnections: TunnelConnection[];
jumpHosts?: JumpHost[];
quickActions?: QuickAction[];
statsConfig?: string;
statsConfig?: string | Record<string, unknown>;
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<string, unknown>;
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 {

View File

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

View File

@@ -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<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [credentials, setCredentials] = useState<
Array<{ id: number; username: string; authType: string }>
>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
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<FormData> = {
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<FormData> = {
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<string, unknown> = {
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<SSHHost> = {
...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({
<span
className="truncate"
title={
field.value?.name || t("hosts.upload")
(field.value as File)?.name ||
t("hosts.upload")
}
>
{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")}
</span>
</Button>
@@ -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({
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="socks5">
<AccordionTrigger>
{t("hosts.socks5Proxy")}
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<Alert>
<AlertDescription>
{t("hosts.socks5Description")}
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="useSocks5"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{t("hosts.enableSocks5")}</FormLabel>
<FormDescription>
{t("hosts.enableSocks5Description")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("useSocks5") && (
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5ProxyMode")}
</FormLabel>
<div className="flex gap-2">
<Button
type="button"
variant={
proxyMode === "single"
? "default"
: "outline"
}
onClick={() => setProxyMode("single")}
className="flex-1"
>
{t("hosts.socks5UseSingleProxy")}
</Button>
<Button
type="button"
variant={
proxyMode === "chain"
? "default"
: "outline"
}
onClick={() => setProxyMode("chain")}
className="flex-1"
>
{t("hosts.socks5UseProxyChain")}
</Button>
</div>
</div>
{proxyMode === "single" && (
<div className="space-y-4 p-4 border rounded-lg">
<FormField
control={form.control}
name="socks5Host"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Host")}
</FormLabel>
<FormControl>
<Input
placeholder="proxy.example.com"
{...field}
/>
</FormControl>
<FormDescription>
{t("hosts.socks5HostDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="socks5Port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Port")}
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1080"
{...field}
onChange={(e) =>
field.onChange(
parseInt(e.target.value) || 1080,
)
}
/>
</FormControl>
<FormDescription>
{t("hosts.socks5PortDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="socks5Username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Username")} (
{t("hosts.optional")})
</FormLabel>
<FormControl>
<Input
placeholder={t("hosts.username")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="socks5Password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.socks5Password")} (
{t("hosts.optional")})
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("hosts.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
)}
{proxyMode === "chain" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<FormLabel>
{t("hosts.socks5ProxyChain")}
</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentChain =
form.watch("socks5ProxyChain") || [];
form.setValue("socks5ProxyChain", [
...currentChain,
{
host: "",
port: 1080,
type: 5 as 4 | 5,
username: "",
password: "",
},
]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addProxyNode")}
</Button>
</div>
{(form.watch("socks5ProxyChain") || [])
.length === 0 && (
<div className="text-sm text-muted-foreground text-center p-4 border rounded-lg border-dashed">
{t("hosts.noProxyNodes")}
</div>
)}
{(form.watch("socks5ProxyChain") || []).map(
(node: any, index: number) => (
<div
key={index}
className="p-4 border rounded-lg space-y-3 relative"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
{t("hosts.proxyNode")} {index + 1}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const currentChain =
form.watch("socks5ProxyChain") ||
[];
form.setValue(
"socks5ProxyChain",
currentChain.filter(
(_: any, i: number) =>
i !== index,
),
);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Host")}
</FormLabel>
<Input
placeholder="proxy.example.com"
value={node.host}
onChange={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
host: e.target.value,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Port")}
</FormLabel>
<Input
type="number"
placeholder="1080"
value={node.port}
onChange={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
port:
parseInt(e.target.value) ||
1080,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
</div>
<div className="space-y-2">
<FormLabel>
{t("hosts.proxyType")}
</FormLabel>
<Select
value={String(node.type)}
onValueChange={(value) => {
const currentChain =
form.watch("socks5ProxyChain") ||
[];
const newChain = [...currentChain];
newChain[index] = {
...newChain[index],
type: parseInt(value) as 4 | 5,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="4">
SOCKS4
</SelectItem>
<SelectItem value="5">
SOCKS5
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Username")} (
{t("hosts.optional")})
</FormLabel>
<Input
placeholder={t("hosts.username")}
value={node.username || ""}
onChange={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
username: e.target.value,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
<div className="space-y-2">
<FormLabel>
{t("hosts.socks5Password")} (
{t("hosts.optional")})
</FormLabel>
<PasswordInput
placeholder={t("hosts.password")}
value={node.password || ""}
onChange={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
password: e.target.value,
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
</div>
</div>
),
)}
</div>
)}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</TabsContent>
<TabsContent value="terminal" className="space-y-1">
<FormField
control={form.control}
name="enableTerminal"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableTerminal")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t("hosts.enableTerminalDesc")}
</FormDescription>
</FormItem>
)}
/>
<Alert className="mt-4 mb-4">
<AlertDescription>
{t("hosts.terminalCustomizationNotice")}
</AlertDescription>
</Alert>
<h1 className="text-xl font-semibold mt-7">
{t("hosts.terminalCustomization")}
</h1>
<Accordion type="multiple" className="w-full">
<TabsContent value="terminal">
<Accordion
type="multiple"
className="w-full"
defaultValue={["appearance", "behavior", "advanced"]}
>
<AccordionItem value="appearance">
<AccordionTrigger>
{t("hosts.appearance")}
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("hosts.themePreview")}
</label>
<TerminalPreview
theme={form.watch("terminalConfig.theme")}
fontSize={form.watch("terminalConfig.fontSize")}
fontFamily={form.watch("terminalConfig.fontFamily")}
cursorStyle={form.watch(
"terminalConfig.cursorStyle",
)}
cursorBlink={form.watch(
"terminalConfig.cursorBlink",
)}
letterSpacing={form.watch(
"terminalConfig.letterSpacing",
)}
lineHeight={form.watch("terminalConfig.lineHeight")}
/>
</div>
<FormField
control={form.control}
name="terminalConfig.theme"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.theme")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectTheme")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(TERMINAL_THEMES).map(
([key, theme]) => (
<SelectItem key={key} value={key}>
{theme.name}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseColorTheme")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fontFamily")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectFont")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{TERMINAL_FONTS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t("hosts.selectFontDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fontSizeValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={8}
max={24}
step={1}
value={[field.value]}
onValueChange={([value]) =>
field.onChange(value)
}
/>
</FormControl>
<FormDescription>
{t("hosts.adjustFontSize")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.letterSpacing"

View File

@@ -183,6 +183,12 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: host.pin,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
};
await connectTunnel(tunnelConfig);

View File

@@ -931,6 +931,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
: 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<Record<string, unknown>> {
try {