SOCKS5 support #452
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>>(
|
||||
|
||||
160
src/backend/utils/socks5-helper.ts
Normal file
160
src/backend/utils/socks5-helper.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Не удалось загрузить хосты",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user