feat: 添加跳板机功能
This commit is contained in:
@@ -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<boolean> => {
|
||||
const jumpChainColumnExists = await columnExists(db, 'connections', 'jump_chain');
|
||||
const proxyTypeColumnExists = await columnExists(db, 'connections', 'proxy_type');
|
||||
return !jumpChainColumnExists || !proxyTypeColumnExists;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
@@ -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<FullConnectionData, 'jump_chain' | 'tag_ids'> { // 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<ConnectionWithTags[]> => {
|
||||
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<ConnectionWithTags[]
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
const rows = await allDb<ConnectionWithTagsRow>(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<ConnectionWithTags[]
|
||||
export const findConnectionByIdWithTags = async (id: number): Promise<ConnectionWithTags | null> => {
|
||||
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<Connection
|
||||
const db = await getDbInstance();
|
||||
const row = await getDbRow<ConnectionWithTagsRow>(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<Connection
|
||||
export const findFullConnectionById = async (id: number): Promise<FullConnectionDbRow | null> => {
|
||||
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<FullConnection
|
||||
* 根据名称查找连接 (用于检查名称是否重复)
|
||||
*/
|
||||
export const findConnectionByName = async (name: string): Promise<ConnectionBase | null> => {
|
||||
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<ConnectionBase>(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<ConnectionWithTagsRow>(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<FullConnection
|
||||
/**
|
||||
* 创建新连接 (不处理标签)
|
||||
*/
|
||||
// Update input type to reflect FullConnectionData now has 'type'
|
||||
// Update input type to reflect FullConnectionData now has 'type' and 'jump_chain'
|
||||
export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'>): Promise<number> => {
|
||||
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<Omit<FullConnectionData, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>>): Promise<boolean> => {
|
||||
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<Omit<FullConnec
|
||||
fieldsToUpdate.updated_at = Math.floor(Date.now() / 1000);
|
||||
|
||||
const setClauses = Object.keys(fieldsToUpdate).map(key => `${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<Omit<FullConnec
|
||||
|
||||
params.push(id);
|
||||
const sql = `UPDATE connections SET ${setClauses} WHERE id = ?`;
|
||||
console.log(`[Repository:updateConnection] SQL for ID ${id}:`, sql);
|
||||
console.log(`[Repository:updateConnection] Params for ID ${id}:`, JSON.stringify(params, null, 2));
|
||||
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
@@ -348,7 +400,7 @@ export const bulkInsertConnections = async (
|
||||
connections: Array<Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { 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
|
||||
];
|
||||
|
||||
@@ -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: 测试未保存连接的客户端已关闭。`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user