feat: 添加跳板机功能
This commit is contained in:
@@ -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 }),
|
||||
|
||||
@@ -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: 测试未保存连接的客户端已关闭。`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
};
|
||||
Reference in New Issue
Block a user