feat: 添加跳板机功能

This commit is contained in:
Baobhan Sith
2025-05-26 19:18:17 +08:00
parent 9524351b90
commit 3c895d5bd7
14 changed files with 971 additions and 320 deletions
@@ -7,11 +7,50 @@ import {
ConnectionWithTags,
CreateConnectionInput,
UpdateConnectionInput,
FullConnectionData
} from '../types/connection.types';
FullConnectionData,
ConnectionWithTags as ConnectionWithTagsType // Alias to avoid conflict with variable name
} from '../types/connection.types';
export type { ConnectionBase, ConnectionWithTags, CreateConnectionInput, UpdateConnectionInput };
/**
* 辅助函数:验证 jump_chain 并处理与 proxy_id 的互斥关系
* @param jumpChain 输入的 jump_chain
* @param proxyId 输入的 proxy_id
* @param connectionId 当前正在操作的连接ID (仅在更新时提供)
* @returns 处理过的 jump_chain (null 如果无效或应被忽略)
* @throws Error 如果验证失败
*/
const _validateAndProcessJumpChain = async (
jumpChain: number[] | null | undefined,
proxyId: number | null | undefined,
connectionId?: number
): Promise<number[] | null> => {
if (!jumpChain || jumpChain.length === 0) {
return null;
}
const validatedChain: number[] = [];
for (const id of jumpChain) {
if (typeof id !== 'number') {
throw new Error('jump_chain 中的 ID 必须是数字。');
}
if (connectionId && id === connectionId) {
throw new Error(`jump_chain 不能包含当前连接自身的 ID (${connectionId})。`);
}
const existingConnection = await ConnectionRepository.findConnectionByIdWithTags(id);
if (!existingConnection) {
throw new Error(`jump_chain 中的连接 ID ${id} 未找到。`);
}
if (existingConnection.type !== 'SSH') {
throw new Error(`jump_chain 中的连接 ID ${id} (${existingConnection.name}) 不是 SSH 类型。`);
}
validatedChain.push(id);
}
return validatedChain.length > 0 ? validatedChain : null;
};
const auditLogService = new AuditLogService();
@@ -38,9 +77,14 @@ export const getConnectionById = async (id: number): Promise<ConnectionWithTags
*/
export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
// +++ Define a local type alias for clarity, including ssh_key_id +++
type ConnectionDataForRepo = Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'>;
type ConnectionDataForRepo = Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'> & { jump_chain?: number[] | null; proxy_type?: 'proxy' | 'jump' | null };
console.log('[Service:createConnection] Received input:', JSON.stringify(input, null, 2)); // Log input
// 0. 处理和验证 jump_chain
const processedJumpChain = await _validateAndProcessJumpChain(input.jump_chain, input.proxy_id);
// 1. 验证输入 (包含 type)
// Convert type to uppercase for validation and consistency
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined; // Ensure type safety
@@ -154,14 +198,16 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
encrypted_passphrase: encryptedPassphrase, // Null if using ssh_key_id or RDP
ssh_key_id: sshKeyIdToSave, // +++ Add ssh_key_id +++
notes: input.notes ?? null, // Add notes field
proxy_id: input.proxy_id ?? null,
proxy_id: input.proxy_id ?? null, // 直接使用输入的 proxy_id
proxy_type: input.proxy_type ?? null, // 新增 proxy_type
jump_chain: processedJumpChain,
};
// Remove ssh_key_id property if it's null before logging/saving if repository expects exact type match without optional nulls
const finalConnectionData = { ...connectionData };
if (finalConnectionData.ssh_key_id === null) {
delete (finalConnectionData as any).ssh_key_id; // Adjust based on repository function signature if needed
}
console.log('[Service:createConnection] Data to be saved:', JSON.stringify(finalConnectionData, null, 2)); // Log data before saving
console.log('[Service:createConnection] Data being passed to ConnectionRepository.createConnection:', JSON.stringify(finalConnectionData, null, 2)); // Log data before saving
// 4. 在仓库中创建连接记录
// Pass the potentially modified finalConnectionData
@@ -197,12 +243,36 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
}
// 2. 准备更新数据
// Explicitly type dataToUpdate to match the repository's expected input, including ssh_key_id
const dataToUpdate: Partial<Omit<ConnectionRepository.FullConnectionData & { ssh_key_id?: number | null }, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>> = {};
// Explicitly type dataToUpdate to match the repository's expected input, including ssh_key_id, jump_chain and proxy_type
const dataToUpdate: Partial<Omit<ConnectionRepository.FullConnectionData & { ssh_key_id?: number | null; jump_chain?: number[] | null; proxy_type?: 'proxy' | 'jump' | null }, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>> = {};
let needsCredentialUpdate = false;
// Determine the final type, converting input type to uppercase if provided
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined || currentFullConnection.type;
// 处理 jump_chain 和 proxy_id
if (input.jump_chain !== undefined || input.proxy_id !== undefined) {
const currentProxyId = input.proxy_id !== undefined ? input.proxy_id : currentFullConnection.proxy_id;
let jumpChainFromDb: number[] | null = null;
if (currentFullConnection.jump_chain) { // currentFullConnection.jump_chain is string | null
try {
jumpChainFromDb = JSON.parse(currentFullConnection.jump_chain) as number[];
} catch (e) {
console.error(`[Service:updateConnection] Failed to parse jump_chain from DB for connection ${id}: ${currentFullConnection.jump_chain}`, e);
// Treat as null if parsing fails, or consider throwing an error
jumpChainFromDb = null;
}
}
const currentJumpChainForValidation: number[] | null | undefined = input.jump_chain !== undefined ? input.jump_chain : jumpChainFromDb;
const processedJumpChain = await _validateAndProcessJumpChain(currentJumpChainForValidation, currentProxyId, id);
dataToUpdate.jump_chain = processedJumpChain;
// 直接使用 currentProxyId,不再因为 jump_chain 存在而将其设为 null
dataToUpdate.proxy_id = currentProxyId;
}
// 更新非凭证字段
if (input.name !== undefined) dataToUpdate.name = input.name || '';
// Update type if changed, using the uppercase version
@@ -210,8 +280,10 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
if (input.host !== undefined) dataToUpdate.host = input.host;
if (input.port !== undefined) dataToUpdate.port = input.port;
if (input.username !== undefined) dataToUpdate.username = input.username;
if (input.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes update
if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id;
if (input.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes update
// proxy_id 的处理已移至 jump_chain 逻辑块中
// if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id;
if (input.proxy_type !== undefined) dataToUpdate.proxy_type = input.proxy_type; // 新增 proxy_type 更新
// Handle ssh_key_id update (can be set to null or a new ID)
if (input.ssh_key_id !== undefined) dataToUpdate.ssh_key_id = input.ssh_key_id;
@@ -336,6 +408,7 @@ if (input.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes up
let updatedFieldsForAudit: string[] = []; // 跟踪审计日志的字段
if (hasNonTagChanges) {
updatedFieldsForAudit = Object.keys(dataToUpdate); // 在更新调用之前获取字段
console.log(`[Service:updateConnection] Data being passed to ConnectionRepository.updateConnection for ID ${id}:`, JSON.stringify(dataToUpdate, null, 2)); // ADD THIS LOG
const updated = await ConnectionRepository.updateConnection(id, dataToUpdate);
if (!updated) {
// 如果 findFullConnectionById 成功,则不应发生这种情况,但这是良好的实践
@@ -504,6 +577,9 @@ export const cloneConnection = async (originalId: number, newName: string): Prom
encrypted_passphrase: originalFullConnection.encrypted_passphrase ?? null,
ssh_key_id: originalFullConnection.ssh_key_id ?? null, // 保留原始的 ssh_key_id
proxy_id: originalFullConnection.proxy_id ?? null,
proxy_type: originalFullConnection.proxy_type ?? null, // 新增 proxy_type 复制
notes: originalFullConnection.notes ?? null, // 确保 notes 被复制
jump_chain: originalFullConnection.jump_chain ? JSON.parse(originalFullConnection.jump_chain) as number[] : null, // 复制并解析 jump_chain
// 移除不存在的 RDP 字段复制
// ...(originalFullConnection.rdp_security && { rdp_security: originalFullConnection.rdp_security }),
// ...(originalFullConnection.rdp_ignore_cert !== undefined && { rdp_ignore_cert: originalFullConnection.rdp_ignore_cert }),
+538 -222
View File
@@ -3,14 +3,39 @@ import { SocksClient, SocksClientOptions } from 'socks';
import http from 'http';
import net from 'net';
import * as ConnectionRepository from '../repositories/connection.repository';
import * as ProxyRepository from '../repositories/proxy.repository';
import * as ProxyRepository from '../repositories/proxy.repository';
import { decrypt } from '../utils/crypto';
import * as SshKeyService from './ssh_key.service'; // +++ Import SshKeyService +++
import * as SshKeyService from './ssh_key.service';
const CONNECT_TIMEOUT = 20000; // 连接超时时间 (毫秒)
const TEST_TIMEOUT = 15000; // 测试连接超时时间 (毫秒)
// 辅助接口:定义解密后的凭证和代理信息结构 (导出以便 websocket.ts 使用)
interface JumpHostRawConfig {
id?: string | number; // Optional: an identifier for the hop from config
name?: string; // Optional: a name for the hop from config
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
encrypted_password?: string | null;
ssh_key_id?: number | null;
encrypted_private_key?: string | null;
encrypted_passphrase?: string | null;
}
export interface JumpHostDetail {
id: string; // Unique ID for this hop instance (e.g., generated or from config)
name?: string; // Optional name for logging
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
password?: string;
privateKey?: string;
passphrase?: string;
}
export interface DecryptedConnectionDetails {
id: number;
name: string;
@@ -29,10 +54,9 @@ export interface DecryptedConnectionDetails {
port: number;
username?: string;
password?: string; // Decrypted
// auth_method?: string; // Proxy auth method (如果需要可以保留)
// privateKey?: string; // Decrypted proxy key (如果需要可以保留)
// passphrase?: string; // Decrypted proxy passphrase (如果需要可以保留)
} | null;
jump_chain?: JumpHostDetail[];
connection_proxy_setting?: 'proxy' | 'jump' | null;
}
/**
@@ -42,89 +66,505 @@ export interface DecryptedConnectionDetails {
* @throws Error 如果连接配置未找到或解密失败
*/
export const getConnectionDetails = async (connectionId: number): Promise<DecryptedConnectionDetails> => {
console.log(`SshService: 获取连接 ${connectionId} 的详细信息...`);
console.log(`SshService: getConnectionDetails - 获取连接 ${connectionId} 的详细信息...`);
const rawConnInfo = await ConnectionRepository.findFullConnectionById(connectionId);
if (!rawConnInfo) {
console.error(`SshService: 连接配置 ID ${connectionId} 未找到。`);
throw new Error(`连接配置 ID ${connectionId} 未找到。`);
}
const typedRawConnInfo = rawConnInfo as typeof rawConnInfo & { jump_chain?: string | null; proxy_type?: 'proxy' | 'jump' | null };
try {
const fullConnInfo: DecryptedConnectionDetails = {
id: rawConnInfo.id,
// Add null check for required fields from rawConnInfo
name: rawConnInfo.name ?? (() => { throw new Error(`Connection ID ${connectionId} has null name.`); })(),
host: rawConnInfo.host ?? (() => { throw new Error(`Connection ID ${connectionId} has null host.`); })(),
port: rawConnInfo.port ?? (() => { throw new Error(`Connection ID ${connectionId} has null port.`); })(),
username: rawConnInfo.username ?? (() => { throw new Error(`Connection ID ${connectionId} has null username.`); })(),
auth_method: rawConnInfo.auth_method ?? (() => { throw new Error(`Connection ID ${connectionId} has null auth_method.`); })(),
// Initialize credentials
id: typedRawConnInfo.id,
name: typedRawConnInfo.name ?? (() => { throw new Error(`Connection ID ${connectionId} has null name.`); })(),
host: typedRawConnInfo.host ?? (() => { throw new Error(`Connection ID ${connectionId} has null host.`); })(),
port: typedRawConnInfo.port ?? (() => { throw new Error(`Connection ID ${connectionId} has null port.`); })(),
username: typedRawConnInfo.username ?? (() => { throw new Error(`Connection ID ${connectionId} has null username.`); })(),
auth_method: typedRawConnInfo.auth_method ?? (() => { throw new Error(`Connection ID ${connectionId} has null auth_method.`); })(),
password: undefined,
privateKey: undefined,
passphrase: undefined,
proxy: null,
jump_chain: undefined,
connection_proxy_setting: typedRawConnInfo.proxy_type ?? null,
};
// Decrypt password if method is password
if (fullConnInfo.auth_method === 'password' && rawConnInfo.encrypted_password) {
fullConnInfo.password = decrypt(rawConnInfo.encrypted_password);
}
// Handle key auth: prioritize ssh_key_id, then direct key
else if (fullConnInfo.auth_method === 'key') {
// +++ Use rawConnInfo.ssh_key_id instead of undefined sshKeyId +++
if (rawConnInfo.ssh_key_id) {
console.log(`SshService: Connection ${connectionId} uses stored SSH key ID: ${rawConnInfo.ssh_key_id}. Fetching key...`);
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(rawConnInfo.ssh_key_id); // Use imported SshKeyService
if (typedRawConnInfo.ssh_key_id) {
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(typedRawConnInfo.ssh_key_id);
if (!storedKeyDetails) {
console.error(`SshService: Error: Connection ${connectionId} references non-existent SSH key ID ${rawConnInfo.ssh_key_id}`);
throw new Error(`关联的 SSH 密钥 (ID: ${rawConnInfo.ssh_key_id}) 未找到。`);
console.error(`SshService: Error: Connection ${connectionId} references non-existent SSH key ID ${typedRawConnInfo.ssh_key_id}`);
throw new Error(`关联的 SSH 密钥 (ID: ${typedRawConnInfo.ssh_key_id}) 未找到。`);
}
fullConnInfo.privateKey = storedKeyDetails.privateKey;
fullConnInfo.passphrase = storedKeyDetails.passphrase;
console.log(`SshService: Successfully fetched and decrypted stored SSH key ${rawConnInfo.ssh_key_id} for connection ${connectionId}.`);
} else if (rawConnInfo.encrypted_private_key) {
// Decrypt direct key only if ssh_key_id is not present
fullConnInfo.privateKey = decrypt(rawConnInfo.encrypted_private_key);
if (rawConnInfo.encrypted_passphrase) {
fullConnInfo.passphrase = decrypt(rawConnInfo.encrypted_passphrase);
} else if (typedRawConnInfo.encrypted_private_key) {
fullConnInfo.privateKey = decrypt(typedRawConnInfo.encrypted_private_key);
if (typedRawConnInfo.encrypted_passphrase) {
fullConnInfo.passphrase = decrypt(typedRawConnInfo.encrypted_passphrase);
}
} else {
console.warn(`SshService: Connection ${connectionId} uses key auth but has neither ssh_key_id nor encrypted_private_key.`);
}
}
if (rawConnInfo.proxy_db_id) {
// Add null checks for required proxy fields inside the if block
const proxyName = rawConnInfo.proxy_name ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null name.`); })();
const proxyType = rawConnInfo.proxy_type ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null type.`); })();
const proxyHost = rawConnInfo.proxy_host ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null host.`); })();
const proxyPort = rawConnInfo.proxy_port ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null port.`); })();
// Ensure proxyType is one of the allowed values
if (typedRawConnInfo.proxy_db_id) {
const proxyName = typedRawConnInfo.proxy_name ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null name.`); })();
const proxyType = typedRawConnInfo.actual_proxy_server_type ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} (actual_proxy_server_type) has null type.`); })();
const proxyHost = typedRawConnInfo.proxy_host ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null host.`); })();
const proxyPort = typedRawConnInfo.proxy_port ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null port.`); })();
if (proxyType !== 'SOCKS5' && proxyType !== 'HTTP') {
throw new Error(`Proxy for Connection ID ${connectionId} has invalid type: ${proxyType}`);
throw new Error(`Proxy for Connection ID ${connectionId} has invalid actual_proxy_server_type: ${proxyType}`);
}
fullConnInfo.proxy = {
id: rawConnInfo.proxy_db_id, // Already checked by the if condition
id: typedRawConnInfo.proxy_db_id,
name: proxyName,
type: proxyType, // Already validated
type: proxyType,
host: proxyHost,
port: proxyPort,
username: rawConnInfo.proxy_username || undefined, // Optional, defaults to undefined
password: rawConnInfo.proxy_encrypted_password ? decrypt(rawConnInfo.proxy_encrypted_password) : undefined, // Optional, handled by decrypt logic
// 可以根据需要解密代理的其他凭证
username: typedRawConnInfo.proxy_username || undefined,
password: typedRawConnInfo.proxy_encrypted_password ? decrypt(typedRawConnInfo.proxy_encrypted_password) : undefined,
};
}
console.log(`SshService: 连接 ${connectionId} 的详细信息获取并解密成功。`);
// 修改条件判断和 JSON.parse 以使用正确的字段名 jump_chain
if (typedRawConnInfo.jump_chain) {
try {
const jumpHostConnectionIds: number[] = JSON.parse(typedRawConnInfo.jump_chain);
if (Array.isArray(jumpHostConnectionIds) && jumpHostConnectionIds.length > 0) {
fullConnInfo.jump_chain = []; // Initialize for JumpHostDetail objects
for (let i = 0; i < jumpHostConnectionIds.length; i++) {
const hopConnectionId = jumpHostConnectionIds[i];
if (typeof hopConnectionId !== 'number') {
throw new Error(`Jump host ID at index ${i} in jump_chain for connection ${connectionId} is not a number. Found: ${hopConnectionId}`);
}
if (hopConnectionId === connectionId) {
throw new Error(`Connection ${connectionId} cannot have itself (ID: ${hopConnectionId}) in its own jump_chain. This would cause a loop.`);
}
const hopTargetDetails: DecryptedConnectionDetails = await getConnectionDetails(hopConnectionId);
const decryptedHop: JumpHostDetail = {
id: `hop-${connectionId}-via-${hopConnectionId}-idx-${i}`, // A unique ID for this specific hop in this chain
name: hopTargetDetails.name || `Jump Host ${i + 1} (Conn ID ${hopConnectionId})`,
host: hopTargetDetails.host,
port: hopTargetDetails.port,
username: hopTargetDetails.username,
auth_method: hopTargetDetails.auth_method,
// Credentials should already be decrypted by the recursive call
password: hopTargetDetails.password,
privateKey: hopTargetDetails.privateKey,
passphrase: hopTargetDetails.passphrase,
};
fullConnInfo.jump_chain.push(decryptedHop);
}
} else {
console.log(`SshService: Parsed jump_chain for connection ${connectionId} is empty or not an array after parsing.`);
}
} catch (parseOrProcessError: any) {
console.error(`SshService: Failed to parse or process jump_chain for connection ${connectionId}. Raw jump_chain: "${typedRawConnInfo.jump_chain}". Error:`, parseOrProcessError);
throw new Error(`解析或处理跳板机配置失败 (连接ID ${connectionId}): ${parseOrProcessError.message}`);
}
} else {
console.log(`SshService: Connection ${connectionId} does not have jump_chain configuration in DB, or it is null/empty string.`);
}
return fullConnInfo;
} catch (decryptError: any) {
console.error(`SshService: 处理连接 ${connectionId} 凭证代理凭证失败:`, decryptError);
throw new Error(`处理凭证失败: ${decryptError.message}`);
console.error(`SshService: 处理连接 ${connectionId} 凭证代理或跳板机凭证失败:`, decryptError);
throw new Error(`处理凭证或配置失败: ${decryptError.message}`);
}
};
// --- Helper function to set up SSH client listeners and initiate connection ---
const _setupSshClientListenersAndConnect = (
client: Client,
config: ConnectConfig,
isFinalClient: boolean,
connectionIdForUpdate: number | null,
connNameForLog: string
): Promise<Client> => {
return new Promise((resolve, reject) => {
const logPrefix = `SshService: Client for ${connNameForLog} (ID: ${connectionIdForUpdate ?? 'N/A'}, ${isFinalClient ? 'Final' : 'Intermediate'}) -`;
const eventHandlers = {
ready: async () => {
console.log(`${logPrefix} SSH connection successful. Target: ${config.host || (config.sock ? 'stream-based' : 'unknown')}`);
client.removeListener('error', eventHandlers.error);
client.removeListener('close', eventHandlers.close);
if (isFinalClient && connectionIdForUpdate !== null && connectionIdForUpdate !== -1) { // -1 for unsaved tests
try {
const currentTimeSeconds = Math.floor(Date.now() / 1000);
await ConnectionRepository.updateLastConnected(connectionIdForUpdate, currentTimeSeconds);
} catch (updateError) {
console.error(`SshService: Failed to update last_connected_at for connection ID ${connectionIdForUpdate}:`, updateError);
}
}
resolve(client);
},
error: (err: Error) => {
client.removeListener('ready', eventHandlers.ready);
client.removeListener('error', eventHandlers.error);
client.removeListener('close', eventHandlers.close);
console.error(`${logPrefix} SSH connection error:`, err);
try { client.end(); } catch (e) { console.error(`${logPrefix} Error ending client in errorHandler:`, e); }
reject(err);
},
close: () => {
client.removeListener('ready', eventHandlers.ready);
client.removeListener('error', eventHandlers.error);
client.removeListener('close', eventHandlers.close);
console.warn(`${logPrefix} SSH connection closed.`);
}
};
client.once('ready', eventHandlers.ready);
client.on('error', eventHandlers.error);
client.on('close', eventHandlers.close);
console.log(`${logPrefix} Attempting to connect... Config: host=${config.host}, port=${config.port}, user=${config.username}, sock=${!!config.sock}`);
client.connect(config);
});
};
// --- Helper function for direct SSH connection ---
const _establishDirectSshConnection = (
connDetails: DecryptedConnectionDetails,
timeout: number
): Promise<Client> => {
const sshClient = new Client();
const connectConfig: ConnectConfig = {
host: connDetails.host,
port: connDetails.port,
username: connDetails.username,
password: connDetails.password,
privateKey: connDetails.privateKey,
passphrase: connDetails.passphrase,
readyTimeout: timeout,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
};
return _setupSshClientListenersAndConnect(
sshClient,
connectConfig,
true, // isFinalClient
connDetails.id,
connDetails.name
);
};
// --- Helper functions for proxy connections ---
const _connectViaSocksProxy = (
destinationHost: string,
destinationPort: number,
proxyDetails: NonNullable<DecryptedConnectionDetails['proxy']>,
timeout: number
): Promise<net.Socket> => {
return new Promise((resolve, reject) => {
const socksOptions: SocksClientOptions = {
proxy: {
host: proxyDetails.host,
port: proxyDetails.port,
type: 5,
userId: proxyDetails.username,
password: proxyDetails.password
},
command: 'connect',
destination: { host: destinationHost, port: destinationPort },
timeout: timeout,
};
SocksClient.createConnection(socksOptions)
.then(({ socket }) => {
resolve(socket);
})
.catch(socksError => {
const errMsg = `SOCKS5 proxy ${proxyDetails.host}:${proxyDetails.port} connection failed: ${socksError.message}`;
console.error(`SshService: ${errMsg}`);
reject(new Error(errMsg));
});
});
};
const _connectViaHttpProxy = (
destinationHost: string,
destinationPort: number,
proxyDetails: NonNullable<DecryptedConnectionDetails['proxy']>,
timeout: number
): Promise<net.Socket> => {
return new Promise((resolve, reject) => {
const reqOptions: http.RequestOptions = {
method: 'CONNECT',
host: proxyDetails.host,
port: proxyDetails.port,
path: `${destinationHost}:${destinationPort}`,
timeout: timeout,
agent: false
};
if (proxyDetails.username) {
const auth = 'Basic ' + Buffer.from(proxyDetails.username + ':' + (proxyDetails.password || '')).toString('base64');
reqOptions.headers = {
...reqOptions.headers,
'Proxy-Authorization': auth,
'Proxy-Connection': 'Keep-Alive',
'Host': `${destinationHost}:${destinationPort}`
};
}
const req = http.request(reqOptions);
req.on('connect', (res, socket, head) => {
if (res.statusCode === 200) {
resolve(socket);
} else {
socket.destroy();
const errMsg = `HTTP proxy ${proxyDetails.host}:${proxyDetails.port} connection failed (status: ${res.statusCode})`;
console.error(`SshService: ${errMsg}`);
reject(new Error(errMsg));
}
});
req.on('error', (err) => {
const errMsg = `HTTP proxy ${proxyDetails.host}:${proxyDetails.port} request error: ${err.message}`;
console.error(`SshService: ${errMsg}`);
reject(new Error(errMsg));
});
req.on('timeout', () => {
req.destroy();
const errMsg = `HTTP proxy ${proxyDetails.host}:${proxyDetails.port} connection timed out`;
console.error(`SshService: ${errMsg}`);
reject(new Error(errMsg));
});
req.end();
});
};
const _establishProxyConnection = async (
connDetails: DecryptedConnectionDetails,
timeout: number
): Promise<Client> => {
const proxy = connDetails.proxy!;
const sshClient = new Client();
const baseConnectConfig: ConnectConfig = {
username: connDetails.username,
password: connDetails.password,
privateKey: connDetails.privateKey,
passphrase: connDetails.passphrase,
readyTimeout: timeout,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
};
try {
let proxySocket: net.Socket;
if (proxy.type === 'SOCKS5') {
proxySocket = await _connectViaSocksProxy(connDetails.host, connDetails.port, proxy, timeout);
} else if (proxy.type === 'HTTP') {
proxySocket = await _connectViaHttpProxy(connDetails.host, connDetails.port, proxy, timeout);
} else {
throw new Error(`Unsupported proxy type: ${proxy.type}`);
}
const connectConfigWithSocket: ConnectConfig = {
...baseConnectConfig,
sock: proxySocket,
// host and port are for the final destination; ssh2 uses sock if provided
host: connDetails.host, // Kept for clarity/logging, not strictly needed by ssh2 with sock
port: connDetails.port, // Kept for clarity/logging
};
return _setupSshClientListenersAndConnect(
sshClient,
connectConfigWithSocket,
true, // isFinalClient
connDetails.id,
connDetails.name
);
} catch (proxyError: any) {
console.error(`SshService: Proxy connection setup failed for ${connDetails.name}: ${proxyError.message}`);
try { sshClient.end(); } catch(e) { /* ignore */ }
throw proxyError;
}
};
// --- Helper function for preparing ConnectConfig for each jump hop ---
function _prepareConnectConfigForHop(
hopDetail: JumpHostDetail,
previousStream: ClientChannel | null,
timeout: number
): ConnectConfig {
const config: ConnectConfig = {
username: hopDetail.username,
readyTimeout: timeout,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
};
if (hopDetail.auth_method === 'password') {
config.password = hopDetail.password;
} else {
config.privateKey = hopDetail.privateKey;
config.passphrase = hopDetail.passphrase;
}
if (previousStream) {
config.sock = previousStream as any; // ssh2 types ClientChannel, but it's a Duplex stream
} else {
config.host = hopDetail.host;
config.port = hopDetail.port;
}
return config;
}
// --- Core recursive logic for multi-hop SSH connection ---
async function _establishConnectionViaJumpChainRecursive(
hopIndex: number,
previousStream: ClientChannel | null,
jumpChainDetails: JumpHostDetail[],
finalTargetDetails: DecryptedConnectionDetails,
activeClients: Client[], // Stores successfully connected intermediate clients for cleanup
timeoutPerHop: number
): Promise<Client> {
return new Promise<Client>(async (resolveOuter, rejectOuter) => {
const cleanupAndReject = (error: Error, clientOnError?: Client) => {
console.error(`SshService: JumpChainCleanupAndReject (Hop ${hopIndex + 1}) for ${finalTargetDetails.name}. Error: ${error.message}`);
if (clientOnError) {
try {
clientOnError.end();
} catch (e) {
console.error(`SshService: Error ending clientOnError during jump chain cleanup:`, (e as Error).message);
}
}
activeClients.forEach(client => {
try {
client.end();
} catch (e) {
console.error(`SshService: Error ending an active client during jump chain cleanup:`, (e as Error).message);
}
});
activeClients.length = 0; // Clear the array
rejectOuter(error);
};
// Base case: All jump hosts are connected. Now connect to the final target.
if (hopIndex === jumpChainDetails.length) {
if (!previousStream) {
console.error(`SshService: JumpHop[BaseCase] Error - Jump chain exhausted but no stream to final target for ${finalTargetDetails.name}.`);
return cleanupAndReject(new Error("SshService: Jump chain exhausted but no stream to final target. This indicates an internal logic error."));
}
const finalClient = new Client();
const finalConnectConfig: ConnectConfig = {
sock: previousStream as any,
username: finalTargetDetails.username,
password: finalTargetDetails.password,
privateKey: finalTargetDetails.privateKey,
passphrase: finalTargetDetails.passphrase,
readyTimeout: timeoutPerHop,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
};
_setupSshClientListenersAndConnect(
finalClient,
finalConnectConfig,
true, // isFinalClient
finalTargetDetails.id,
finalTargetDetails.name
)
.then(client => {
resolveOuter(client); // Successfully connected to final target
})
.catch(err => {
cleanupAndReject(new Error(`Final target connection error for ${finalTargetDetails.name} (via jump chain): ${err.message}`), finalClient);
});
return; // Exit promise executor
}
// Recursive step: Connect to the current jump host
const currentJumpHostDetails = jumpChainDetails[hopIndex];
const currentHopLogPrefix = `SshService: JumpHop[${hopIndex + 1}/${jumpChainDetails.length}] (${currentJumpHostDetails.name || currentJumpHostDetails.host}:${currentJumpHostDetails.port}) -> `;
console.log(`${currentHopLogPrefix}Connecting to jump host: ${currentJumpHostDetails.host}:${currentJumpHostDetails.port} (User: ${currentJumpHostDetails.username}, Auth: ${currentJumpHostDetails.auth_method}). PreviousStream exists: ${!!previousStream}`);
const currentHopClient = new Client();
const connectConfigForThisHop = _prepareConnectConfigForHop(currentJumpHostDetails, previousStream, timeoutPerHop);
console.log(`${currentHopLogPrefix}Prepared connect config for this hop: Host=${connectConfigForThisHop.host}, Port=${connectConfigForThisHop.port}, SockPresent=${!!connectConfigForThisHop.sock}`);
// Define specific handlers for this intermediate hop
const currentHopHandlers = {
ready: () => {
currentHopClient.removeListener('error', currentHopHandlers.error);
currentHopClient.removeListener('close', currentHopHandlers.close);
activeClients.push(currentHopClient); // Add to activeClients only AFTER successful connection
console.log(`${currentHopLogPrefix}Successfully connected.`);
const isLastJumpHost = hopIndex === jumpChainDetails.length - 1;
const nextTargetHost = isLastJumpHost ? finalTargetDetails.host : jumpChainDetails[hopIndex + 1].host;
const nextTargetPort = isLastJumpHost ? finalTargetDetails.port : jumpChainDetails[hopIndex + 1].port;
console.log(`${currentHopLogPrefix}Attempting forwardOut to ${nextTargetHost}:${nextTargetPort}`);
currentHopClient.forwardOut(
'127.0.0.1', 0, nextTargetHost, nextTargetPort, // Listen on any local port on the jump host
(err, nextStream) => {
if (err) {
console.error(`${currentHopLogPrefix}forwardOut to ${nextTargetHost}:${nextTargetPort} FAILED:`, err);
// currentHopClient is in activeClients, cleanupAndReject will handle it.
return cleanupAndReject(new Error(`${currentHopLogPrefix}forwardOut to ${nextTargetHost}:${nextTargetPort} failed: ${err.message}`), currentHopClient);
}
console.log(`${currentHopLogPrefix}forwardOut to ${nextTargetHost}:${nextTargetPort} successful. Proceeding to next hop or target with new stream.`);
_establishConnectionViaJumpChainRecursive(
hopIndex + 1, nextStream, jumpChainDetails, finalTargetDetails, activeClients, timeoutPerHop
)
.then(resolveOuter) // Propagate success
.catch(rejectOuter); // Propagate failure (cleanup handled by deeper calls or cleanupAndReject)
}
);
},
error: (err: Error) => {
console.error(`${currentHopLogPrefix}Connection ERROR:`, err);
currentHopClient.removeListener('ready', currentHopHandlers.ready); // 'ready' is once
currentHopClient.removeListener('close', currentHopHandlers.close);
cleanupAndReject(new Error(`${currentHopLogPrefix}connection error: ${err.message}`), currentHopClient);
},
close: () => {
currentHopClient.removeListener('ready', currentHopHandlers.ready); // 'ready' is once
currentHopClient.removeListener('error', currentHopHandlers.error);
console.warn(`${currentHopLogPrefix}Connection closed unexpectedly.`);
// This might indicate the hop dropped. If the promise for this hop hasn't settled,
// it should be an error. cleanupAndReject will handle this if it's called.
// To be safe, if the outer promise hasn't been settled, reject it.
// However, 'error' event usually precedes an unexpected close that causes failure.
// If this 'close' happens after 'ready' and during 'forwardOut' or deeper recursion,
// the failure will be caught by those stages or the final client.
}
};
currentHopClient.once('ready', currentHopHandlers.ready);
currentHopClient.on('error', currentHopHandlers.error);
currentHopClient.on('close', currentHopHandlers.close);
console.log(`${currentHopLogPrefix}Attempting to connect. Config: host=${connectConfigForThisHop.host}, port=${connectConfigForThisHop.port}, user=${connectConfigForThisHop.username}, sock=${!!connectConfigForThisHop.sock}`);
currentHopClient.connect(connectConfigForThisHop);
});
}
/**
* 根据解密后的连接详情建立 SSH 连接(处理代理)
* 根据解密后的连接详情建立 SSH 连接(处理代理和跳板机
* @param connDetails - 解密后的连接详情
* @param timeout - 连接超时时间 (毫秒),可选
* @returns Promise<Client> 连接成功的 SSH Client 实例
@@ -134,150 +574,39 @@ export const establishSshConnection = (
connDetails: DecryptedConnectionDetails,
timeout: number = CONNECT_TIMEOUT
): Promise<Client> => {
return new Promise((resolve, reject) => {
const sshClient = new Client();
const connectConfig: ConnectConfig = {
host: connDetails.host,
port: connDetails.port,
username: connDetails.username,
password: connDetails.password,
privateKey: connDetails.privateKey,
passphrase: connDetails.passphrase,
readyTimeout: timeout,
keepaliveInterval: 5000, // 修改:每 5 秒发送一次 keepalive
keepaliveCountMax: 10, // 修改:最多尝试 10 次 (总超时约 10*5=50 秒)
};
const readyHandler = async () => { // 改为 async 函数
console.log(`SshService: SSH 连接到 ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id}) 成功。`);
sshClient.removeListener('error', errorHandler); // 成功后移除错误监听器
try {
const currentTimeSeconds = Math.floor(Date.now() / 1000);
await ConnectionRepository.updateLastConnected(connDetails.id, currentTimeSeconds);
console.log(`SshService: 已更新连接 ${connDetails.id} 的 last_connected_at 为 ${currentTimeSeconds}`);
} catch (updateError) {
// 更新失败不应阻止连接成功,但需要记录错误
console.error(`SshService: 更新连接 ${connDetails.id} 的 last_connected_at 失败:`, updateError);
}
resolve(sshClient); // 返回 Client 实例
};
const errorHandler = (err: Error) => {
// Ensure this handler only runs once effectively
sshClient.removeListener('ready', readyHandler);
sshClient.removeListener('error', errorHandler); // Remove itself
sshClient.removeListener('close', closeHandler); // Remove close handler if attached
console.error(`SshService: SSH 连接到 ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id}) 失败:`, err);
// Try ending the client gracefully, but don't wait for it if it hangs
try {
sshClient.end();
} catch (endError) {
console.error(`SshService: Error while calling sshClient.end() during error handling:`, endError);
}
reject(err); // Reject the promise
};
const closeHandler = () => {
// Handle unexpected close events if needed, or just ensure listeners are removed
sshClient.removeListener('ready', readyHandler);
sshClient.removeListener('error', errorHandler);
sshClient.removeListener('close', closeHandler);
// console.log(`SshService: SSH connection closed unexpectedly during connection phase for ${connDetails.id}`);
// Avoid rejecting here, let the 'error' handler manage rejection on failure.
};
// Modify readyHandler to remove error and close listeners
const originalReadyHandler = readyHandler; // Keep original logic
const enhancedReadyHandler = async () => {
sshClient.removeListener('error', errorHandler); // Remove error listener on success
sshClient.removeListener('close', closeHandler); // Remove close listener on success
await originalReadyHandler(); // Execute original logic (updates DB, resolves promise)
};
sshClient.once('ready', enhancedReadyHandler); // Use enhanced handler
sshClient.on('error', errorHandler); // Use 'on' but handler removes itself
sshClient.on('close', closeHandler); // Add a handler for close events during connection phase
// --- 处理代理 ---
// Make sure the proxy error handling also calls the main errorHandler
const handleProxyError = (proxyError: Error) => {
console.error(`SshService: Proxy setup failed for ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id})`, proxyError);
// Call the main error handler to ensure consistent cleanup and rejection
errorHandler(proxyError);
};
if (connDetails.proxy) {
const proxy = connDetails.proxy;
console.log(`SshService: 应用代理 ${proxy.name} (${proxy.type}) 连接到 ${connDetails.host}:${connDetails.port}`);
if (proxy.type === 'SOCKS5') {
const socksOptions: SocksClientOptions = {
proxy: { host: proxy.host, port: proxy.port, type: 5, userId: proxy.username, password: proxy.password },
command: 'connect',
destination: { host: connectConfig.host!, port: connectConfig.port! },
timeout: connectConfig.readyTimeout,
};
SocksClient.createConnection(socksOptions)
.then(({ socket }) => {
console.log(`SshService: SOCKS5 代理连接成功 (目标: ${connDetails.host}:${connDetails.port})。`);
connectConfig.sock = socket;
sshClient.connect(connectConfig);
})
.catch(socksError => {
handleProxyError(new Error(`SOCKS5 代理 ${proxy.host}:${proxy.port} 连接失败: ${socksError.message}`));
});
} else if (proxy.type === 'HTTP') {
console.log(`SshService: 尝试通过 HTTP 代理 ${proxy.host}:${proxy.port} 建立隧道到 ${connDetails.host}:${connDetails.port}...`);
const reqOptions: http.RequestOptions = {
method: 'CONNECT',
host: proxy.host,
port: proxy.port,
path: `${connectConfig.host}:${connectConfig.port}`,
timeout: connectConfig.readyTimeout,
agent: false
};
if (proxy.username) {
const auth = 'Basic ' + Buffer.from(proxy.username + ':' + (proxy.password || '')).toString('base64');
reqOptions.headers = { ...reqOptions.headers, 'Proxy-Authorization': auth, 'Proxy-Connection': 'Keep-Alive', 'Host': `${connectConfig.host}:${connectConfig.port}` };
}
const req = http.request(reqOptions);
req.on('connect', (res, socket, head) => {
if (res.statusCode === 200) {
console.log(`SshService: HTTP 代理隧道建立成功 (目标: ${connDetails.host}:${connDetails.port})。`);
connectConfig.sock = socket;
sshClient.connect(connectConfig);
} else {
socket.destroy();
handleProxyError(new Error(`HTTP 代理 ${proxy.host}:${proxy.port} 连接失败 (状态码: ${res.statusCode})`));
}
});
req.on('error', (err) => {
handleProxyError(new Error(`HTTP 代理 ${proxy.host}:${proxy.port} 请求错误: ${err.message}`));
});
req.on('timeout', () => {
req.destroy();
handleProxyError(new Error(`HTTP 代理 ${proxy.host}:${proxy.port} 连接超时`));
});
req.end();
} else {
handleProxyError(new Error(`不支持的代理类型: ${proxy.type}`));
}
if (connDetails.connection_proxy_setting === 'jump') {
if (connDetails.jump_chain && connDetails.jump_chain.length > 0) {
// Log details of each jump host
connDetails.jump_chain.forEach((hop, index) => {
});
return _establishConnectionViaJumpChainRecursive(
0, // hopIndex
null, // previousStream
connDetails.jump_chain,
connDetails, // finalTargetDetails
[], // activeClients (for cleanup of intermediate hops)
timeout // timeoutPerHop (can be refined if needed per hop)
);
} else {
// 无代理,直接连接
console.log(`SshService: 无代理,直接连接到 ${connDetails.host}:${connDetails.port}`);
sshClient.connect(connectConfig);
console.warn(`SshService: Connection ${connDetails.name} set to 'jump' but jump_chain is MISSING or EMPTY. Attempting direct connection as fallback.`);
return _establishDirectSshConnection(connDetails, timeout);
}
});
} else if (connDetails.connection_proxy_setting === 'proxy') {
if (connDetails.proxy) {
return _establishProxyConnection(connDetails, timeout);
} else {
console.warn(`SshService: Connection ${connDetails.name} set to 'proxy' but proxy details are MISSING. Attempting direct connection as fallback.`);
return _establishDirectSshConnection(connDetails, timeout);
}
} else {
if (connDetails.connection_proxy_setting && connDetails.connection_proxy_setting !== null && connDetails.connection_proxy_setting !== undefined) {
}
return _establishDirectSshConnection(connDetails, timeout);
}
};
/**
* 在已连接的 SSH Client 上打开 Shell 通道
* @param sshClient - 已连接的 SSH Client 实例
@@ -306,23 +635,18 @@ export const openShell = (sshClient: Client): Promise<ClientChannel> => {
export const testConnection = async (connectionId: number): Promise<{ latency: number }> => {
console.log(`SshService: 测试连接 ${connectionId}...`);
let sshClient: Client | null = null;
const startTime = Date.now(); // 开始计时
const startTime = Date.now();
try {
// 1. 获取并解密连接信息
const connDetails = await getConnectionDetails(connectionId);
// 2. 尝试建立连接 (使用较短的测试超时时间)
sshClient = await establishSshConnection(connDetails, TEST_TIMEOUT);
const endTime = Date.now(); // 结束计时
const endTime = Date.now();
const latency = endTime - startTime;
console.log(`SshService: 测试连接 ${connectionId} 成功,延迟: ${latency}ms。`);
return { latency }; // 返回延迟
return { latency };
} catch (error) {
console.error(`SshService: 测试连接 ${connectionId} 失败:`, error);
throw error; // 将错误向上抛出
throw error;
} finally {
// 无论成功失败,都关闭 SSH 客户端
if (sshClient) {
sshClient.end();
console.log(`SshService: 测试连接 ${connectionId} 的客户端已关闭。`);
@@ -333,62 +657,56 @@ export const testConnection = async (connectionId: number): Promise<{ latency: n
/**
* 测试未保存的 SSH 连接信息(包括代理)
* @param connectionConfig - 包含连接参数的对象 (host, port, username, auth_method, password?, private_key?, passphrase?, proxy_id?)
* @param connectionConfig - 包含连接参数的对象
* @returns Promise<{ latency: number }> - 如果连接成功则 resolve 包含延迟的对象,否则 reject
* @throws Error 如果连接失败或配置错误
*/
// Ensure ssh_key_id is part of the input type definition
export const testUnsavedConnection = async (connectionConfig: {
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
password?: string;
private_key?: string; // Keep this for direct input
private_key?: string;
passphrase?: string;
ssh_key_id?: number | null; // Ensure this is present
ssh_key_id?: number | null;
proxy_id?: number | null;
}): Promise<{ latency: number }> => {
console.log(`SshService: 测试未保存的连接到 ${connectionConfig.host}:${connectionConfig.port}...`);
let sshClient: Client | null = null;
const startTime = Date.now(); // 开始计时
const startTime = Date.now();
try {
// 1. 构建临时的 DecryptedConnectionDetails 结构
const tempConnDetails: DecryptedConnectionDetails = {
id: -1, // 临时 ID,不实际使用
name: `Test-${connectionConfig.host}`, // 临时名称
id: -1, // Temporary ID for non-persistent connection
name: `Test-${connectionConfig.host}`,
host: connectionConfig.host,
port: connectionConfig.port,
username: connectionConfig.username,
auth_method: connectionConfig.auth_method,
// Initialize credentials, will be populated based on input
password: undefined,
privateKey: undefined,
passphrase: undefined,
proxy: null, // 稍后填充
proxy: null,
connection_proxy_setting: connectionConfig.proxy_id ? 'proxy' : null,
};
// Populate credentials based on auth method and ssh_key_id presence
if (tempConnDetails.auth_method === 'password') {
tempConnDetails.password = connectionConfig.password;
} else { // auth_method is 'key'
} else {
if (connectionConfig.ssh_key_id) {
// Fetch and decrypt stored key if ssh_key_id is provided
console.log(`SshService: Testing unsaved connection using stored SSH key ID: ${connectionConfig.ssh_key_id}...`);
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(connectionConfig.ssh_key_id); // Use imported SshKeyService
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(connectionConfig.ssh_key_id);
if (!storedKeyDetails) {
throw new Error(`选择的 SSH 密钥 (ID: ${connectionConfig.ssh_key_id}) 未找到。`);
}
tempConnDetails.privateKey = storedKeyDetails.privateKey;
tempConnDetails.passphrase = storedKeyDetails.passphrase;
} else {
// Use direct key input if ssh_key_id is not provided
tempConnDetails.privateKey = connectionConfig.private_key; // Use private_key from input
tempConnDetails.privateKey = connectionConfig.private_key;
tempConnDetails.passphrase = connectionConfig.passphrase;
}
}
// 2. 如果提供了 proxy_id,获取并解密代理信息
if (connectionConfig.proxy_id) {
console.log(`SshService: 测试连接需要获取代理 ${connectionConfig.proxy_id} 的信息...`);
const rawProxyInfo = await ProxyRepository.findProxyById(connectionConfig.proxy_id);
@@ -396,13 +714,11 @@ export const testUnsavedConnection = async (connectionConfig: {
throw new Error(`代理 ID ${connectionConfig.proxy_id} 未找到。`);
}
try {
// Add null checks for required proxy fields
const proxyName = rawProxyInfo.name ?? (() => { throw new Error(`Proxy ID ${connectionConfig.proxy_id} has null name.`); })();
const proxyType = rawProxyInfo.type ?? (() => { throw new Error(`Proxy ID ${connectionConfig.proxy_id} has null type.`); })();
const proxyHost = rawProxyInfo.host ?? (() => { throw new Error(`Proxy ID ${connectionConfig.proxy_id} has null host.`); })();
const proxyPort = rawProxyInfo.port ?? (() => { throw new Error(`Proxy ID ${connectionConfig.proxy_id} has null port.`); })();
// Ensure proxyType is one of the allowed values
if (proxyType !== 'SOCKS5' && proxyType !== 'HTTP') {
throw new Error(`Proxy ID ${connectionConfig.proxy_id} has invalid type: ${proxyType}`);
}
@@ -416,29 +732,29 @@ export const testUnsavedConnection = async (connectionConfig: {
username: rawProxyInfo.username || undefined,
password: rawProxyInfo.encrypted_password ? decrypt(rawProxyInfo.encrypted_password) : undefined,
};
tempConnDetails.connection_proxy_setting = 'proxy'; // Ensure this is set
console.log(`SshService: 代理 ${connectionConfig.proxy_id} 信息获取并解密成功。`);
} catch (decryptError: any) {
console.error(`SshService: 处理代理 ${connectionConfig.proxy_id} 凭证失败:`, decryptError);
throw new Error(`处理代理凭证失败: ${decryptError.message}`);
}
} else {
tempConnDetails.connection_proxy_setting = null; // Explicitly no proxy
}
// 3. 尝试建立连接 (使用较短的测试超时时间)
sshClient = await establishSshConnection(tempConnDetails, TEST_TIMEOUT);
const endTime = Date.now(); // 结束计时
const endTime = Date.now();
const latency = endTime - startTime;
console.log(`SshService: 测试未保存的连接到 ${connectionConfig.host}:${connectionConfig.port} 成功,延迟: ${latency}ms。`);
return { latency }; // 返回延迟
return { latency };
} catch (error) {
console.error(`SshService: 测试未保存的连接到 ${connectionConfig.host}:${connectionConfig.port} 失败:`, error);
throw error; // 将错误向上抛出
throw error;
} finally {
// 无论成功失败,都关闭 SSH 客户端
if (sshClient) {
sshClient.end();
console.log(`SshService: 测试未保存连接的客户端已关闭。`);
}
}
};
};