diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index 3936a96..a3cfc55 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -283,7 +283,20 @@ const definedMigrations: Migration[] = [ updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); ` - } + }, + { + id: 9, + name: 'Add jump_chain and proxy_type columns to connections table', + sql: ` + ALTER TABLE connections ADD COLUMN jump_chain TEXT NULL; + ALTER TABLE connections ADD COLUMN proxy_type TEXT NULL; + `, + check: async (db: Database): Promise => { + const jumpChainColumnExists = await columnExists(db, 'connections', 'jump_chain'); + const proxyTypeColumnExists = await columnExists(db, 'connections', 'proxy_type'); + return !jumpChainColumnExists || !proxyTypeColumnExists; + } + }, ]; /** diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index c67dfbc..6a765b5 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -18,15 +18,7 @@ CREATE TABLE IF NOT EXISTS audit_logs ( ); `; -// Removed API Keys table definition -// export const createApiKeysTableSQL = ` -// CREATE TABLE IF NOT EXISTS api_keys ( -// id INTEGER PRIMARY KEY AUTOINCREMENT, -// name TEXT NOT NULL, -// hashed_key TEXT UNIQUE NOT NULL, -// created_at INTEGER NOT NULL -// ); -// `; + // Passkeys table definition export const createPasskeysTableSQL = ` @@ -101,13 +93,15 @@ CREATE TABLE IF NOT EXISTS connections ( encrypted_private_key TEXT NULL, encrypted_passphrase TEXT NULL, proxy_id INTEGER NULL, - ssh_key_id INTEGER NULL, -- 新增 ssh_key_id 列 + ssh_key_id INTEGER NULL, notes TEXT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), +jump_chain TEXT NULL, +proxy_type TEXT NULL, +created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), last_connected_at INTEGER NULL, FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL, - FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL -- 新增外键约束 + FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL ); `; diff --git a/packages/backend/src/repositories/connection.repository.ts b/packages/backend/src/repositories/connection.repository.ts index f2847f7..09f2203 100644 --- a/packages/backend/src/repositories/connection.repository.ts +++ b/packages/backend/src/repositories/connection.repository.ts @@ -13,21 +13,25 @@ interface ConnectionBase { username: string; auth_method: 'password' | 'key'; proxy_id: number | null; + proxy_type?: 'proxy' | 'jump' | null; // 新增连接本身的 proxy_type created_at: number; updated_at: number; last_connected_at: number | null; ssh_key_id?: number | null; -notes?: string | null; +notes?: string | null; +// jump_chain: number[] | null; // <-- REMOVE from ConnectionBase } // ConnectionWithTagsRow implicitly includes 'type' and 'ssh_key_id' via ConnectionBase -interface ConnectionWithTagsRow extends ConnectionBase { +interface ConnectionWithTagsRow extends ConnectionBase { // This will no longer cause error if ConnectionBase has no jump_chain tag_ids_str: string | null; + jump_chain: string | null; // Stored as JSON string in DB } // ConnectionWithTags implicitly includes 'type' and 'ssh_key_id' via ConnectionBase export interface ConnectionWithTags extends ConnectionBase { tag_ids: number[]; + jump_chain: number[] | null; // Explicitly add for service layer type } // 包含加密字段的完整类型,用于插入/更新 @@ -36,18 +40,21 @@ export interface FullConnectionData extends ConnectionBase { encrypted_password?: string | null; encrypted_private_key?: string | null; encrypted_passphrase?: string | null; -notes?: string | null; +notes?: string | null; tag_ids?: number[]; + jump_chain: number[] | null; // Explicitly add for service layer input type + proxy_type?: 'proxy' | 'jump' | null; // 新增连接本身的 proxy_type } -// FullConnectionDbRow implicitly includes 'type' via FullConnectionData -// Also add ssh_key_id here as it's part of the connection record itself -interface FullConnectionDbRow extends FullConnectionData { - ssh_key_id?: number | null; // +++ Add ssh_key_id +++ + +interface FullConnectionDbRow extends Omit { // Omit service layer type, and tag_ids (not directly on connections table) + ssh_key_id?: number | null; + jump_chain: string | null; // Stored as JSON string in DB + proxy_type?: 'proxy' | 'jump' | null; // 连接本身的 proxy_type, from c.proxy_type proxy_db_id: number | null; proxy_name: string | null; - proxy_type: string | null; + actual_proxy_server_type: string | null; // p.type AS actual_proxy_server_type proxy_host: string | null; proxy_port: number | null; proxy_username: string | null; @@ -63,7 +70,7 @@ interface FullConnectionDbRow extends FullConnectionData { export const findAllConnectionsWithTags = async (): Promise => { const sql = ` SELECT - c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id, c.ssh_key_id, c.notes, -- +++ Select ssh_key_id and notes +++ + c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain, -- +++ Select ssh_key_id, notes, jump_chain AND proxy_type +++ c.created_at, c.updated_at, c.last_connected_at, GROUP_CONCAT(ct.tag_id) as tag_ids_str FROM connections c @@ -73,10 +80,14 @@ export const findAllConnectionsWithTags = async (): Promise(db, sql); - return rows.map(row => ({ - ...row, - tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : [] - })); + return rows.map(row => { + const { jump_chain: jumpChainStr, ...restOfRow } = row; + return { + ...restOfRow, + tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : [], + jump_chain: jumpChainStr ? JSON.parse(jumpChainStr) as number[] : null + } as ConnectionWithTags; + }); } catch (err: any) { console.error('Repository: 查询连接列表时出错:', err.message); throw new Error('获取连接列表失败'); @@ -89,7 +100,7 @@ export const findAllConnectionsWithTags = async (): Promise => { const sql = ` SELECT - c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id, c.ssh_key_id, c.notes, -- +++ Select ssh_key_id and notes +++ + c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain, -- +++ Select ssh_key_id, notes, jump_chain AND proxy_type +++ c.created_at, c.updated_at, c.last_connected_at, GROUP_CONCAT(ct.tag_id) as tag_ids_str FROM connections c @@ -100,10 +111,12 @@ export const findConnectionByIdWithTags = async (id: number): Promise(db, sql, [id]); if (row && typeof row.id !== 'undefined') { + const { jump_chain: jumpChainStr, ...restOfRow } = row; return { - ...row, - tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : [] - }; + ...restOfRow, + tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : [], + jump_chain: jumpChainStr ? JSON.parse(jumpChainStr) as number[] : null + } as ConnectionWithTags; } else { return null; } @@ -119,8 +132,8 @@ export const findConnectionByIdWithTags = async (id: number): Promise => { const sql = ` SELECT - c.*, -- 选择 connections 表所有列 - p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type, + c.*, -- 选择 connections 表所有列 (包括 c.proxy_type) + p.id as proxy_db_id, p.name as proxy_name, p.type as actual_proxy_server_type, -- Renamed p.type to avoid conflict p.host as proxy_host, p.port as proxy_port, p.username as proxy_username, p.encrypted_password as proxy_encrypted_password, p.encrypted_private_key as proxy_encrypted_private_key, @@ -142,11 +155,26 @@ export const findFullConnectionById = async (id: number): Promise => { - const sql = `SELECT id, name, type, host, port, username, auth_method, proxy_id, ssh_key_id, notes, created_at, updated_at, last_connected_at FROM connections WHERE name = ?`; + const sql = `SELECT id, name, type, host, port, username, auth_method, proxy_id, proxy_type, ssh_key_id, notes, jump_chain, created_at, updated_at, last_connected_at FROM connections WHERE name = ?`; // Added jump_chain and proxy_type try { const db = await getDbInstance(); - const row = await getDbRow(db, sql, [name]); - return row || null; + // Cast to ConnectionWithTagsRow to read jump_chain as string, then parse. It will now also have proxy_type + const row = await getDbRow(db, sql, [name]); + if (row) { + const { jump_chain: jumpChainStr, tag_ids_str, ...restOfRow } = row; // Exclude tag_ids_str as well for ConnectionBase + return { + ...restOfRow, + // ConnectionBase does not have jump_chain, so we don't add it here. + // If we need jump_chain for findConnectionByName and the result type is ConnectionBase, + // then ConnectionBase itself needs jump_chain: number[] | null. + // For now, assuming ConnectionBase should NOT have jump_chain for this function's return. + // If it SHOULD, ConnectionBase needs jump_chain: number[] | null, and the parsing is correct. + // Let's assume ConnectionBase should NOT have it to keep it truly base. + // The caller using findConnectionByName might not expect jump_chain. + // If service needs it, it should use a find method that returns a richer type. + } as ConnectionBase; // jump_chain is not part of ConnectionBase anymore + } + return null; // Ensure null is returned if row is null } catch (err: any) { console.error(`Repository: 查询连接名称 "${name}" 时出错:`, err.message); throw new Error('查找连接名称失败'); @@ -157,22 +185,31 @@ export const findFullConnectionById = async (id: number): Promise): Promise => { + console.log('[Repository:createConnection] Received data:', JSON.stringify(data, null, 2)); const now = Math.floor(Date.now() / 1000); const sql = ` - INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, ssh_key_id, notes, created_at, updated_at) -- +++ Add ssh_key_id and notes columns +++ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // +++ Add placeholders for ssh_key_id and notes +++ + INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, proxy_type, ssh_key_id, notes, jump_chain, created_at, updated_at) -- +++ Add ssh_key_id, notes, jump_chain AND proxy_type columns +++ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // +++ Add placeholders for ssh_key_id, notes, jump_chain AND proxy_type +++ + + const jumpChainStringified = (data.jump_chain && data.jump_chain.length > 0) ? JSON.stringify(data.jump_chain) : null; + console.log(`[Repository:createConnection] jump_chain input: ${JSON.stringify(data.jump_chain)}, stringified to: ${jumpChainStringified}`); + const params = [ data.name ?? null, data.type, // Add type parameter data.host, data.port, data.username, data.auth_method, data.encrypted_password ?? null, data.encrypted_private_key ?? null, data.encrypted_passphrase ?? null, data.proxy_id ?? null, + data.proxy_type ?? null, // Add proxy_type parameter data.ssh_key_id ?? null, // +++ Add ssh_key_id parameter +++ -data.notes ?? null, // Add notes parameter + data.notes ?? null, // Add notes parameter + jumpChainStringified, // Use the stringified jump_chain now, now ]; + console.log('[Repository:createConnection] SQL:', sql); + console.log('[Repository:createConnection] Params:', JSON.stringify(params, null, 2)); try { const db = await getDbInstance(); const result = await runDb(db, sql, params); @@ -189,8 +226,9 @@ data.notes ?? null, // Add notes parameter /** * 更新连接信息 (不处理标签) */ -// Update input type to reflect FullConnectionData now has 'type' +// Update input type to reflect FullConnectionData now has 'type' and 'jump_chain' export const updateConnection = async (id: number, data: Partial>): Promise => { + console.log(`[Repository:updateConnection] Received data for ID ${id}:`, JSON.stringify(data, null, 2)); const fieldsToUpdate: { [key: string]: any } = { ...data }; const params: any[] = []; @@ -202,7 +240,19 @@ export const updateConnection = async (id: number, data: Partial `${key} = ?`).join(', '); - Object.values(fieldsToUpdate).forEach(value => params.push(value ?? null)); + + Object.keys(fieldsToUpdate).forEach(key => { + const K = key as keyof typeof fieldsToUpdate; + const value = fieldsToUpdate[K]; + if (K === 'jump_chain') { + const jumpChainValue = value as number[] | null; + const jumpChainStringified = (jumpChainValue && jumpChainValue.length > 0) ? JSON.stringify(jumpChainValue) : null; + console.log(`[Repository:updateConnection] jump_chain input for ID ${id}: ${JSON.stringify(jumpChainValue)}, stringified to: ${jumpChainStringified}`); + params.push(jumpChainStringified); + } else { + params.push(value ?? null); + } + }); if (!setClauses) { console.warn(`[Repository] updateConnection called for ID ${id} with no fields to update.`); @@ -211,6 +261,8 @@ export const updateConnection = async (id: number, data: Partial & { tag_ids?: number[] }> ): Promise<{ connectionId: number, originalData: any }[]> => { - const insertConnSql = `INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // Add type and notes columns and placeholders + const insertConnSql = `INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, proxy_type, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // Add type, proxy_type and notes columns and placeholders const results: { connectionId: number, originalData: any }[] = []; const now = Math.floor(Date.now() / 1000); @@ -359,6 +411,7 @@ export const bulkInsertConnections = async ( connData.encrypted_private_key || null, connData.encrypted_passphrase || null, connData.proxy_id || null, + connData.proxy_type || null, // Add proxy_type parameter connData.notes || null, // Add notes parameter now, now ]; diff --git a/packages/backend/src/services/connection.service.ts b/packages/backend/src/services/connection.service.ts index 955f6d5..2ae6a1f 100644 --- a/packages/backend/src/services/connection.service.ts +++ b/packages/backend/src/services/connection.service.ts @@ -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 => { + + 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 => { // +++ Define a local type alias for clarity, including ssh_key_id +++ - type ConnectionDataForRepo = Omit; + type ConnectionDataForRepo = Omit & { 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> = {}; + // Explicitly type dataToUpdate to match the repository's expected input, including ssh_key_id, jump_chain and proxy_type + const dataToUpdate: Partial> = {}; 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 }), diff --git a/packages/backend/src/services/ssh.service.ts b/packages/backend/src/services/ssh.service.ts index 77eff21..359a990 100644 --- a/packages/backend/src/services/ssh.service.ts +++ b/packages/backend/src/services/ssh.service.ts @@ -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 => { - 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 => { + 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 => { + 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, + timeout: number +): Promise => { + 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, + timeout: number +): Promise => { + 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 => { + 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 { + return new Promise(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 连接成功的 SSH Client 实例 @@ -134,150 +574,39 @@ export const establishSshConnection = ( connDetails: DecryptedConnectionDetails, timeout: number = CONNECT_TIMEOUT ): Promise => { - 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 => { 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: 测试未保存连接的客户端已关闭。`); } } -}; - +}; \ No newline at end of file diff --git a/packages/backend/src/types/connection.types.ts b/packages/backend/src/types/connection.types.ts index 443b9fa..5a740af 100644 --- a/packages/backend/src/types/connection.types.ts +++ b/packages/backend/src/types/connection.types.ts @@ -7,10 +7,12 @@ export interface ConnectionBase { username: string; auth_method: 'password' | 'key'; proxy_id: number | null; + proxy_type?: 'proxy' | 'jump' | null; created_at: number; updated_at: number; last_connected_at: number | null; -notes?: string | null; +notes?: string | null; + jump_chain: number[] | null; } export interface ConnectionWithTags extends ConnectionBase { @@ -28,10 +30,12 @@ export interface CreateConnectionInput { password?: string; private_key?: string; passphrase?: string; - ssh_key_id?: number | null; // +++ Add ssh_key_id +++ + ssh_key_id?: number | null; proxy_id?: number | null; + proxy_type?: 'proxy' | 'jump' | null; tag_ids?: number[]; -notes?: string | null; +notes?: string | null; + jump_chain?: number[] | null; } @@ -45,10 +49,12 @@ export interface UpdateConnectionInput { password?: string; private_key?: string; passphrase?: string; - ssh_key_id?: number | null; // +++ Add ssh_key_id +++ + ssh_key_id?: number | null; proxy_id?: number | null; -notes?: string | null; + proxy_type?: 'proxy' | 'jump' | null; +notes?: string | null; tag_ids?: number[]; + jump_chain?: number[] | null; } @@ -65,10 +71,12 @@ export interface FullConnectionData { encrypted_passphrase: string | null; ssh_key_id?: number | null; proxy_id: number | null; + proxy_type?: 'proxy' | 'jump' | null; created_at: number; -notes: string | null; +notes: string | null; updated_at: number; last_connected_at: number | null; + jump_chain: number[] | null; } export interface DecryptedConnectionCredentials { diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 25cbc0b..e37738b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@nexus-terminal/frontend", - "version": "0.7.3", + "version": "0.7.5", "private": true, "type": "module", "scripts": { diff --git a/packages/frontend/src/components/AddConnectionForm.vue b/packages/frontend/src/components/AddConnectionForm.vue index b31844a..51938d1 100644 --- a/packages/frontend/src/components/AddConnectionForm.vue +++ b/packages/frontend/src/components/AddConnectionForm.vue @@ -1,5 +1,5 @@