SOCKS5 support

Adding single and chain socks5 proxy support
This commit is contained in:
Denis
2025-12-18 14:36:51 +07:00
parent f0647dc7c1
commit 970ec5d709
19 changed files with 1288 additions and 296 deletions

View File

@@ -208,6 +208,11 @@ async function initializeCompleteDatabase(): Promise<void> {
force_keyboard_interactive TEXT,
stats_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
@@ -487,6 +492,14 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "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

@@ -90,6 +90,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

@@ -242,7 +242,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) ||
@@ -284,6 +297,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") {
@@ -464,6 +483,12 @@ router.put(
statsConfig,
terminalConfig,
forceKeyboardInteractive,
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
} = hostData;
if (
!isNonEmptyString(userId) ||
@@ -507,6 +532,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") {
@@ -690,6 +721,9 @@ router.get(
? JSON.parse(row.terminalConfig as string)
: undefined,
forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
socks5ProxyChain: row.socks5ProxyChain
? JSON.parse(row.socks5ProxyChain as string)
: [],
};
return (await resolveHostCredentials(baseHost)) || baseHost;
@@ -765,6 +799,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);
@@ -836,6 +873,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;
@@ -1183,6 +1190,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;
}
}