This commit is contained in:
Baobhan Sith
2025-05-26 19:23:58 +08:00
15 changed files with 990 additions and 343 deletions
+14 -1
View File
@@ -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;
}
},
];
/**
+6 -12
View File
@@ -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 }),
@@ -261,26 +261,7 @@ export const exportConnectionsAsEncryptedZip = async (includeSshKeys: boolean =
line += ` -p ${escapeCliArgument(conn.password)}`;
}
if (conn.proxy) {
line += ` -proxy-name ${escapeCliArgument(conn.proxy.name)}`;
line += ` -proxy-type ${conn.proxy.type}`;
line += ` -proxy-host ${escapeCliArgument(conn.proxy.host)}`;
line += ` -proxy-port ${conn.proxy.port}`;
if (conn.proxy.username) {
line += ` -proxy-username ${escapeCliArgument(conn.proxy.username)}`;
}
if (conn.proxy.auth_method && conn.proxy.auth_method !== 'none') {
line += ` -proxy-auth-method ${conn.proxy.auth_method}`;
if (conn.proxy.auth_method === 'password' && conn.proxy.password) {
line += ` -proxy-password ${escapeCliArgument(conn.proxy.password)}`;
} else if (conn.proxy.auth_method === 'key' && conn.proxy.private_key) {
line += ` -proxy-key ${escapeCliArgument('key-content-not-exported-for-security')}`;
if (conn.proxy.passphrase) {
line += ` -proxy-passphrase ${escapeCliArgument(conn.proxy.passphrase)}`;
}
}
}
}
// 移除了代理设置的导出,以避免跳板机相关问题
if (conn.tag_ids && conn.tag_ids.length > 0) {
const tagNames = conn.tag_ids.map(id => tagsMap.get(id)).filter(name => !!name) as string[];
@@ -334,8 +315,22 @@ export const exportConnectionsAsEncryptedZip = async (includeSshKeys: boolean =
archive.append(connectionsScriptContent, { name: 'connections.txt' });
if (includeSshKeys && allSshKeys.length > 0) {
const sshKeysJsonContent = JSON.stringify(allSshKeys, null, 2);
archive.append(sshKeysJsonContent, { name: 'ssh_keys.json' });
// 创建一个名为 ssh_keys 的文件夹,并将每个密钥保存为一个单独的文件
// 确保 ssh_keys 目录首先被创建(如果 archive 库不自动创建)
// archive.append(null, { name: 'ssh_keys/', type: 'directory' }); // archiver 会自动创建目录结构
for (const sshKey of allSshKeys) {
// DecryptedSshKeyDetails 包含 name 和 privateKey
if (sshKey.name && sshKey.privateKey) {
// 移除文件名中可能存在的非法字符,或进行更安全的编码
// 为了简单起见,这里假设 sshKey.name 是一个有效的文件名组件
const sanitizedKeyName = sshKey.name.replace(/[<>:"/\\|?*]/g, '_'); // 基本的文件名清理
const filePathInZip = `ssh_keys/${sanitizedKeyName}.txt`;
archive.append(sshKey.privateKey, { name: filePathInZip });
} else {
console.warn(`SSH 密钥 (ID: ${sshKey.id}) 缺少名称或私钥内容,跳过导出。`);
}
}
}
archive.finalize()
@@ -448,7 +443,8 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
encrypted_private_key: connData.encrypted_private_key || null,
encrypted_passphrase: connData.encrypted_passphrase || null,
proxy_id: proxyIdToUse,
tag_ids: connData.tag_ids || []
tag_ids: connData.tag_ids || [],
jump_chain: null, // 为 jump_chain 添加默认值
});
} catch (connError: any) {
+538 -222
View File
@@ -3,14 +3,39 @@ import { SocksClient, SocksClientOptions } from 'socks';
import http from 'http';
import net from 'net';
import * as ConnectionRepository from '../repositories/connection.repository';
import * as ProxyRepository from '../repositories/proxy.repository';
import * as ProxyRepository from '../repositories/proxy.repository';
import { decrypt } from '../utils/crypto';
import * as SshKeyService from './ssh_key.service'; // +++ Import SshKeyService +++
import * as SshKeyService from './ssh_key.service';
const CONNECT_TIMEOUT = 20000; // 连接超时时间 (毫秒)
const TEST_TIMEOUT = 15000; // 测试连接超时时间 (毫秒)
// 辅助接口:定义解密后的凭证和代理信息结构 (导出以便 websocket.ts 使用)
interface JumpHostRawConfig {
id?: string | number; // Optional: an identifier for the hop from config
name?: string; // Optional: a name for the hop from config
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
encrypted_password?: string | null;
ssh_key_id?: number | null;
encrypted_private_key?: string | null;
encrypted_passphrase?: string | null;
}
export interface JumpHostDetail {
id: string; // Unique ID for this hop instance (e.g., generated or from config)
name?: string; // Optional name for logging
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
password?: string;
privateKey?: string;
passphrase?: string;
}
export interface DecryptedConnectionDetails {
id: number;
name: string;
@@ -29,10 +54,9 @@ export interface DecryptedConnectionDetails {
port: number;
username?: string;
password?: string; // Decrypted
// auth_method?: string; // Proxy auth method (如果需要可以保留)
// privateKey?: string; // Decrypted proxy key (如果需要可以保留)
// passphrase?: string; // Decrypted proxy passphrase (如果需要可以保留)
} | null;
jump_chain?: JumpHostDetail[];
connection_proxy_setting?: 'proxy' | 'jump' | null;
}
/**
@@ -42,89 +66,505 @@ export interface DecryptedConnectionDetails {
* @throws Error 如果连接配置未找到或解密失败
*/
export const getConnectionDetails = async (connectionId: number): Promise<DecryptedConnectionDetails> => {
console.log(`SshService: 获取连接 ${connectionId} 的详细信息...`);
console.log(`SshService: getConnectionDetails - 获取连接 ${connectionId} 的详细信息...`);
const rawConnInfo = await ConnectionRepository.findFullConnectionById(connectionId);
if (!rawConnInfo) {
console.error(`SshService: 连接配置 ID ${connectionId} 未找到。`);
throw new Error(`连接配置 ID ${connectionId} 未找到。`);
}
const typedRawConnInfo = rawConnInfo as typeof rawConnInfo & { jump_chain?: string | null; proxy_type?: 'proxy' | 'jump' | null };
try {
const fullConnInfo: DecryptedConnectionDetails = {
id: rawConnInfo.id,
// Add null check for required fields from rawConnInfo
name: rawConnInfo.name ?? (() => { throw new Error(`Connection ID ${connectionId} has null name.`); })(),
host: rawConnInfo.host ?? (() => { throw new Error(`Connection ID ${connectionId} has null host.`); })(),
port: rawConnInfo.port ?? (() => { throw new Error(`Connection ID ${connectionId} has null port.`); })(),
username: rawConnInfo.username ?? (() => { throw new Error(`Connection ID ${connectionId} has null username.`); })(),
auth_method: rawConnInfo.auth_method ?? (() => { throw new Error(`Connection ID ${connectionId} has null auth_method.`); })(),
// Initialize credentials
id: typedRawConnInfo.id,
name: typedRawConnInfo.name ?? (() => { throw new Error(`Connection ID ${connectionId} has null name.`); })(),
host: typedRawConnInfo.host ?? (() => { throw new Error(`Connection ID ${connectionId} has null host.`); })(),
port: typedRawConnInfo.port ?? (() => { throw new Error(`Connection ID ${connectionId} has null port.`); })(),
username: typedRawConnInfo.username ?? (() => { throw new Error(`Connection ID ${connectionId} has null username.`); })(),
auth_method: typedRawConnInfo.auth_method ?? (() => { throw new Error(`Connection ID ${connectionId} has null auth_method.`); })(),
password: undefined,
privateKey: undefined,
passphrase: undefined,
proxy: null,
jump_chain: undefined,
connection_proxy_setting: typedRawConnInfo.proxy_type ?? null,
};
// Decrypt password if method is password
if (fullConnInfo.auth_method === 'password' && rawConnInfo.encrypted_password) {
fullConnInfo.password = decrypt(rawConnInfo.encrypted_password);
}
// Handle key auth: prioritize ssh_key_id, then direct key
else if (fullConnInfo.auth_method === 'key') {
// +++ Use rawConnInfo.ssh_key_id instead of undefined sshKeyId +++
if (rawConnInfo.ssh_key_id) {
console.log(`SshService: Connection ${connectionId} uses stored SSH key ID: ${rawConnInfo.ssh_key_id}. Fetching key...`);
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(rawConnInfo.ssh_key_id); // Use imported SshKeyService
if (typedRawConnInfo.ssh_key_id) {
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(typedRawConnInfo.ssh_key_id);
if (!storedKeyDetails) {
console.error(`SshService: Error: Connection ${connectionId} references non-existent SSH key ID ${rawConnInfo.ssh_key_id}`);
throw new Error(`关联的 SSH 密钥 (ID: ${rawConnInfo.ssh_key_id}) 未找到。`);
console.error(`SshService: Error: Connection ${connectionId} references non-existent SSH key ID ${typedRawConnInfo.ssh_key_id}`);
throw new Error(`关联的 SSH 密钥 (ID: ${typedRawConnInfo.ssh_key_id}) 未找到。`);
}
fullConnInfo.privateKey = storedKeyDetails.privateKey;
fullConnInfo.passphrase = storedKeyDetails.passphrase;
console.log(`SshService: Successfully fetched and decrypted stored SSH key ${rawConnInfo.ssh_key_id} for connection ${connectionId}.`);
} else if (rawConnInfo.encrypted_private_key) {
// Decrypt direct key only if ssh_key_id is not present
fullConnInfo.privateKey = decrypt(rawConnInfo.encrypted_private_key);
if (rawConnInfo.encrypted_passphrase) {
fullConnInfo.passphrase = decrypt(rawConnInfo.encrypted_passphrase);
} else if (typedRawConnInfo.encrypted_private_key) {
fullConnInfo.privateKey = decrypt(typedRawConnInfo.encrypted_private_key);
if (typedRawConnInfo.encrypted_passphrase) {
fullConnInfo.passphrase = decrypt(typedRawConnInfo.encrypted_passphrase);
}
} else {
console.warn(`SshService: Connection ${connectionId} uses key auth but has neither ssh_key_id nor encrypted_private_key.`);
}
}
if (rawConnInfo.proxy_db_id) {
// Add null checks for required proxy fields inside the if block
const proxyName = rawConnInfo.proxy_name ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null name.`); })();
const proxyType = rawConnInfo.proxy_type ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null type.`); })();
const proxyHost = rawConnInfo.proxy_host ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null host.`); })();
const proxyPort = rawConnInfo.proxy_port ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null port.`); })();
// Ensure proxyType is one of the allowed values
if (typedRawConnInfo.proxy_db_id) {
const proxyName = typedRawConnInfo.proxy_name ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null name.`); })();
const proxyType = typedRawConnInfo.actual_proxy_server_type ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} (actual_proxy_server_type) has null type.`); })();
const proxyHost = typedRawConnInfo.proxy_host ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null host.`); })();
const proxyPort = typedRawConnInfo.proxy_port ?? (() => { throw new Error(`Proxy for Connection ID ${connectionId} has null port.`); })();
if (proxyType !== 'SOCKS5' && proxyType !== 'HTTP') {
throw new Error(`Proxy for Connection ID ${connectionId} has invalid type: ${proxyType}`);
throw new Error(`Proxy for Connection ID ${connectionId} has invalid actual_proxy_server_type: ${proxyType}`);
}
fullConnInfo.proxy = {
id: rawConnInfo.proxy_db_id, // Already checked by the if condition
id: typedRawConnInfo.proxy_db_id,
name: proxyName,
type: proxyType, // Already validated
type: proxyType,
host: proxyHost,
port: proxyPort,
username: rawConnInfo.proxy_username || undefined, // Optional, defaults to undefined
password: rawConnInfo.proxy_encrypted_password ? decrypt(rawConnInfo.proxy_encrypted_password) : undefined, // Optional, handled by decrypt logic
// 可以根据需要解密代理的其他凭证
username: typedRawConnInfo.proxy_username || undefined,
password: typedRawConnInfo.proxy_encrypted_password ? decrypt(typedRawConnInfo.proxy_encrypted_password) : undefined,
};
}
console.log(`SshService: 连接 ${connectionId} 的详细信息获取并解密成功。`);
// 修改条件判断和 JSON.parse 以使用正确的字段名 jump_chain
if (typedRawConnInfo.jump_chain) {
try {
const jumpHostConnectionIds: number[] = JSON.parse(typedRawConnInfo.jump_chain);
if (Array.isArray(jumpHostConnectionIds) && jumpHostConnectionIds.length > 0) {
fullConnInfo.jump_chain = []; // Initialize for JumpHostDetail objects
for (let i = 0; i < jumpHostConnectionIds.length; i++) {
const hopConnectionId = jumpHostConnectionIds[i];
if (typeof hopConnectionId !== 'number') {
throw new Error(`Jump host ID at index ${i} in jump_chain for connection ${connectionId} is not a number. Found: ${hopConnectionId}`);
}
if (hopConnectionId === connectionId) {
throw new Error(`Connection ${connectionId} cannot have itself (ID: ${hopConnectionId}) in its own jump_chain. This would cause a loop.`);
}
const hopTargetDetails: DecryptedConnectionDetails = await getConnectionDetails(hopConnectionId);
const decryptedHop: JumpHostDetail = {
id: `hop-${connectionId}-via-${hopConnectionId}-idx-${i}`, // A unique ID for this specific hop in this chain
name: hopTargetDetails.name || `Jump Host ${i + 1} (Conn ID ${hopConnectionId})`,
host: hopTargetDetails.host,
port: hopTargetDetails.port,
username: hopTargetDetails.username,
auth_method: hopTargetDetails.auth_method,
// Credentials should already be decrypted by the recursive call
password: hopTargetDetails.password,
privateKey: hopTargetDetails.privateKey,
passphrase: hopTargetDetails.passphrase,
};
fullConnInfo.jump_chain.push(decryptedHop);
}
} else {
console.log(`SshService: Parsed jump_chain for connection ${connectionId} is empty or not an array after parsing.`);
}
} catch (parseOrProcessError: any) {
console.error(`SshService: Failed to parse or process jump_chain for connection ${connectionId}. Raw jump_chain: "${typedRawConnInfo.jump_chain}". Error:`, parseOrProcessError);
throw new Error(`解析或处理跳板机配置失败 (连接ID ${connectionId}): ${parseOrProcessError.message}`);
}
} else {
console.log(`SshService: Connection ${connectionId} does not have jump_chain configuration in DB, or it is null/empty string.`);
}
return fullConnInfo;
} catch (decryptError: any) {
console.error(`SshService: 处理连接 ${connectionId} 凭证代理凭证失败:`, decryptError);
throw new Error(`处理凭证失败: ${decryptError.message}`);
console.error(`SshService: 处理连接 ${connectionId} 凭证代理或跳板机凭证失败:`, decryptError);
throw new Error(`处理凭证或配置失败: ${decryptError.message}`);
}
};
// --- Helper function to set up SSH client listeners and initiate connection ---
const _setupSshClientListenersAndConnect = (
client: Client,
config: ConnectConfig,
isFinalClient: boolean,
connectionIdForUpdate: number | null,
connNameForLog: string
): Promise<Client> => {
return new Promise((resolve, reject) => {
const logPrefix = `SshService: Client for ${connNameForLog} (ID: ${connectionIdForUpdate ?? 'N/A'}, ${isFinalClient ? 'Final' : 'Intermediate'}) -`;
const eventHandlers = {
ready: async () => {
console.log(`${logPrefix} SSH connection successful. Target: ${config.host || (config.sock ? 'stream-based' : 'unknown')}`);
client.removeListener('error', eventHandlers.error);
client.removeListener('close', eventHandlers.close);
if (isFinalClient && connectionIdForUpdate !== null && connectionIdForUpdate !== -1) { // -1 for unsaved tests
try {
const currentTimeSeconds = Math.floor(Date.now() / 1000);
await ConnectionRepository.updateLastConnected(connectionIdForUpdate, currentTimeSeconds);
} catch (updateError) {
console.error(`SshService: Failed to update last_connected_at for connection ID ${connectionIdForUpdate}:`, updateError);
}
}
resolve(client);
},
error: (err: Error) => {
client.removeListener('ready', eventHandlers.ready);
client.removeListener('error', eventHandlers.error);
client.removeListener('close', eventHandlers.close);
console.error(`${logPrefix} SSH connection error:`, err);
try { client.end(); } catch (e) { console.error(`${logPrefix} Error ending client in errorHandler:`, e); }
reject(err);
},
close: () => {
client.removeListener('ready', eventHandlers.ready);
client.removeListener('error', eventHandlers.error);
client.removeListener('close', eventHandlers.close);
console.warn(`${logPrefix} SSH connection closed.`);
}
};
client.once('ready', eventHandlers.ready);
client.on('error', eventHandlers.error);
client.on('close', eventHandlers.close);
console.log(`${logPrefix} Attempting to connect... Config: host=${config.host}, port=${config.port}, user=${config.username}, sock=${!!config.sock}`);
client.connect(config);
});
};
// --- Helper function for direct SSH connection ---
const _establishDirectSshConnection = (
connDetails: DecryptedConnectionDetails,
timeout: number
): Promise<Client> => {
const sshClient = new Client();
const connectConfig: ConnectConfig = {
host: connDetails.host,
port: connDetails.port,
username: connDetails.username,
password: connDetails.password,
privateKey: connDetails.privateKey,
passphrase: connDetails.passphrase,
readyTimeout: timeout,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
};
return _setupSshClientListenersAndConnect(
sshClient,
connectConfig,
true, // isFinalClient
connDetails.id,
connDetails.name
);
};
// --- Helper functions for proxy connections ---
const _connectViaSocksProxy = (
destinationHost: string,
destinationPort: number,
proxyDetails: NonNullable<DecryptedConnectionDetails['proxy']>,
timeout: number
): Promise<net.Socket> => {
return new Promise((resolve, reject) => {
const socksOptions: SocksClientOptions = {
proxy: {
host: proxyDetails.host,
port: proxyDetails.port,
type: 5,
userId: proxyDetails.username,
password: proxyDetails.password
},
command: 'connect',
destination: { host: destinationHost, port: destinationPort },
timeout: timeout,
};
SocksClient.createConnection(socksOptions)
.then(({ socket }) => {
resolve(socket);
})
.catch(socksError => {
const errMsg = `SOCKS5 proxy ${proxyDetails.host}:${proxyDetails.port} connection failed: ${socksError.message}`;
console.error(`SshService: ${errMsg}`);
reject(new Error(errMsg));
});
});
};
const _connectViaHttpProxy = (
destinationHost: string,
destinationPort: number,
proxyDetails: NonNullable<DecryptedConnectionDetails['proxy']>,
timeout: number
): Promise<net.Socket> => {
return new Promise((resolve, reject) => {
const reqOptions: http.RequestOptions = {
method: 'CONNECT',
host: proxyDetails.host,
port: proxyDetails.port,
path: `${destinationHost}:${destinationPort}`,
timeout: timeout,
agent: false
};
if (proxyDetails.username) {
const auth = 'Basic ' + Buffer.from(proxyDetails.username + ':' + (proxyDetails.password || '')).toString('base64');
reqOptions.headers = {
...reqOptions.headers,
'Proxy-Authorization': auth,
'Proxy-Connection': 'Keep-Alive',
'Host': `${destinationHost}:${destinationPort}`
};
}
const req = http.request(reqOptions);
req.on('connect', (res, socket, head) => {
if (res.statusCode === 200) {
resolve(socket);
} else {
socket.destroy();
const errMsg = `HTTP proxy ${proxyDetails.host}:${proxyDetails.port} connection failed (status: ${res.statusCode})`;
console.error(`SshService: ${errMsg}`);
reject(new Error(errMsg));
}
});
req.on('error', (err) => {
const errMsg = `HTTP proxy ${proxyDetails.host}:${proxyDetails.port} request error: ${err.message}`;
console.error(`SshService: ${errMsg}`);
reject(new Error(errMsg));
});
req.on('timeout', () => {
req.destroy();
const errMsg = `HTTP proxy ${proxyDetails.host}:${proxyDetails.port} connection timed out`;
console.error(`SshService: ${errMsg}`);
reject(new Error(errMsg));
});
req.end();
});
};
const _establishProxyConnection = async (
connDetails: DecryptedConnectionDetails,
timeout: number
): Promise<Client> => {
const proxy = connDetails.proxy!;
const sshClient = new Client();
const baseConnectConfig: ConnectConfig = {
username: connDetails.username,
password: connDetails.password,
privateKey: connDetails.privateKey,
passphrase: connDetails.passphrase,
readyTimeout: timeout,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
};
try {
let proxySocket: net.Socket;
if (proxy.type === 'SOCKS5') {
proxySocket = await _connectViaSocksProxy(connDetails.host, connDetails.port, proxy, timeout);
} else if (proxy.type === 'HTTP') {
proxySocket = await _connectViaHttpProxy(connDetails.host, connDetails.port, proxy, timeout);
} else {
throw new Error(`Unsupported proxy type: ${proxy.type}`);
}
const connectConfigWithSocket: ConnectConfig = {
...baseConnectConfig,
sock: proxySocket,
// host and port are for the final destination; ssh2 uses sock if provided
host: connDetails.host, // Kept for clarity/logging, not strictly needed by ssh2 with sock
port: connDetails.port, // Kept for clarity/logging
};
return _setupSshClientListenersAndConnect(
sshClient,
connectConfigWithSocket,
true, // isFinalClient
connDetails.id,
connDetails.name
);
} catch (proxyError: any) {
console.error(`SshService: Proxy connection setup failed for ${connDetails.name}: ${proxyError.message}`);
try { sshClient.end(); } catch(e) { /* ignore */ }
throw proxyError;
}
};
// --- Helper function for preparing ConnectConfig for each jump hop ---
function _prepareConnectConfigForHop(
hopDetail: JumpHostDetail,
previousStream: ClientChannel | null,
timeout: number
): ConnectConfig {
const config: ConnectConfig = {
username: hopDetail.username,
readyTimeout: timeout,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
};
if (hopDetail.auth_method === 'password') {
config.password = hopDetail.password;
} else {
config.privateKey = hopDetail.privateKey;
config.passphrase = hopDetail.passphrase;
}
if (previousStream) {
config.sock = previousStream as any; // ssh2 types ClientChannel, but it's a Duplex stream
} else {
config.host = hopDetail.host;
config.port = hopDetail.port;
}
return config;
}
// --- Core recursive logic for multi-hop SSH connection ---
async function _establishConnectionViaJumpChainRecursive(
hopIndex: number,
previousStream: ClientChannel | null,
jumpChainDetails: JumpHostDetail[],
finalTargetDetails: DecryptedConnectionDetails,
activeClients: Client[], // Stores successfully connected intermediate clients for cleanup
timeoutPerHop: number
): Promise<Client> {
return new Promise<Client>(async (resolveOuter, rejectOuter) => {
const cleanupAndReject = (error: Error, clientOnError?: Client) => {
console.error(`SshService: JumpChainCleanupAndReject (Hop ${hopIndex + 1}) for ${finalTargetDetails.name}. Error: ${error.message}`);
if (clientOnError) {
try {
clientOnError.end();
} catch (e) {
console.error(`SshService: Error ending clientOnError during jump chain cleanup:`, (e as Error).message);
}
}
activeClients.forEach(client => {
try {
client.end();
} catch (e) {
console.error(`SshService: Error ending an active client during jump chain cleanup:`, (e as Error).message);
}
});
activeClients.length = 0; // Clear the array
rejectOuter(error);
};
// Base case: All jump hosts are connected. Now connect to the final target.
if (hopIndex === jumpChainDetails.length) {
if (!previousStream) {
console.error(`SshService: JumpHop[BaseCase] Error - Jump chain exhausted but no stream to final target for ${finalTargetDetails.name}.`);
return cleanupAndReject(new Error("SshService: Jump chain exhausted but no stream to final target. This indicates an internal logic error."));
}
const finalClient = new Client();
const finalConnectConfig: ConnectConfig = {
sock: previousStream as any,
username: finalTargetDetails.username,
password: finalTargetDetails.password,
privateKey: finalTargetDetails.privateKey,
passphrase: finalTargetDetails.passphrase,
readyTimeout: timeoutPerHop,
keepaliveInterval: 5000,
keepaliveCountMax: 10,
};
_setupSshClientListenersAndConnect(
finalClient,
finalConnectConfig,
true, // isFinalClient
finalTargetDetails.id,
finalTargetDetails.name
)
.then(client => {
resolveOuter(client); // Successfully connected to final target
})
.catch(err => {
cleanupAndReject(new Error(`Final target connection error for ${finalTargetDetails.name} (via jump chain): ${err.message}`), finalClient);
});
return; // Exit promise executor
}
// Recursive step: Connect to the current jump host
const currentJumpHostDetails = jumpChainDetails[hopIndex];
const currentHopLogPrefix = `SshService: JumpHop[${hopIndex + 1}/${jumpChainDetails.length}] (${currentJumpHostDetails.name || currentJumpHostDetails.host}:${currentJumpHostDetails.port}) -> `;
console.log(`${currentHopLogPrefix}Connecting to jump host: ${currentJumpHostDetails.host}:${currentJumpHostDetails.port} (User: ${currentJumpHostDetails.username}, Auth: ${currentJumpHostDetails.auth_method}). PreviousStream exists: ${!!previousStream}`);
const currentHopClient = new Client();
const connectConfigForThisHop = _prepareConnectConfigForHop(currentJumpHostDetails, previousStream, timeoutPerHop);
console.log(`${currentHopLogPrefix}Prepared connect config for this hop: Host=${connectConfigForThisHop.host}, Port=${connectConfigForThisHop.port}, SockPresent=${!!connectConfigForThisHop.sock}`);
// Define specific handlers for this intermediate hop
const currentHopHandlers = {
ready: () => {
currentHopClient.removeListener('error', currentHopHandlers.error);
currentHopClient.removeListener('close', currentHopHandlers.close);
activeClients.push(currentHopClient); // Add to activeClients only AFTER successful connection
console.log(`${currentHopLogPrefix}Successfully connected.`);
const isLastJumpHost = hopIndex === jumpChainDetails.length - 1;
const nextTargetHost = isLastJumpHost ? finalTargetDetails.host : jumpChainDetails[hopIndex + 1].host;
const nextTargetPort = isLastJumpHost ? finalTargetDetails.port : jumpChainDetails[hopIndex + 1].port;
console.log(`${currentHopLogPrefix}Attempting forwardOut to ${nextTargetHost}:${nextTargetPort}`);
currentHopClient.forwardOut(
'127.0.0.1', 0, nextTargetHost, nextTargetPort, // Listen on any local port on the jump host
(err, nextStream) => {
if (err) {
console.error(`${currentHopLogPrefix}forwardOut to ${nextTargetHost}:${nextTargetPort} FAILED:`, err);
// currentHopClient is in activeClients, cleanupAndReject will handle it.
return cleanupAndReject(new Error(`${currentHopLogPrefix}forwardOut to ${nextTargetHost}:${nextTargetPort} failed: ${err.message}`), currentHopClient);
}
console.log(`${currentHopLogPrefix}forwardOut to ${nextTargetHost}:${nextTargetPort} successful. Proceeding to next hop or target with new stream.`);
_establishConnectionViaJumpChainRecursive(
hopIndex + 1, nextStream, jumpChainDetails, finalTargetDetails, activeClients, timeoutPerHop
)
.then(resolveOuter) // Propagate success
.catch(rejectOuter); // Propagate failure (cleanup handled by deeper calls or cleanupAndReject)
}
);
},
error: (err: Error) => {
console.error(`${currentHopLogPrefix}Connection ERROR:`, err);
currentHopClient.removeListener('ready', currentHopHandlers.ready); // 'ready' is once
currentHopClient.removeListener('close', currentHopHandlers.close);
cleanupAndReject(new Error(`${currentHopLogPrefix}connection error: ${err.message}`), currentHopClient);
},
close: () => {
currentHopClient.removeListener('ready', currentHopHandlers.ready); // 'ready' is once
currentHopClient.removeListener('error', currentHopHandlers.error);
console.warn(`${currentHopLogPrefix}Connection closed unexpectedly.`);
// This might indicate the hop dropped. If the promise for this hop hasn't settled,
// it should be an error. cleanupAndReject will handle this if it's called.
// To be safe, if the outer promise hasn't been settled, reject it.
// However, 'error' event usually precedes an unexpected close that causes failure.
// If this 'close' happens after 'ready' and during 'forwardOut' or deeper recursion,
// the failure will be caught by those stages or the final client.
}
};
currentHopClient.once('ready', currentHopHandlers.ready);
currentHopClient.on('error', currentHopHandlers.error);
currentHopClient.on('close', currentHopHandlers.close);
console.log(`${currentHopLogPrefix}Attempting to connect. Config: host=${connectConfigForThisHop.host}, port=${connectConfigForThisHop.port}, user=${connectConfigForThisHop.username}, sock=${!!connectConfigForThisHop.sock}`);
currentHopClient.connect(connectConfigForThisHop);
});
}
/**
* 根据解密后的连接详情建立 SSH 连接(处理代理)
* 根据解密后的连接详情建立 SSH 连接(处理代理和跳板机
* @param connDetails - 解密后的连接详情
* @param timeout - 连接超时时间 (毫秒),可选
* @returns Promise<Client> 连接成功的 SSH Client 实例
@@ -134,150 +574,39 @@ export const establishSshConnection = (
connDetails: DecryptedConnectionDetails,
timeout: number = CONNECT_TIMEOUT
): Promise<Client> => {
return new Promise((resolve, reject) => {
const sshClient = new Client();
const connectConfig: ConnectConfig = {
host: connDetails.host,
port: connDetails.port,
username: connDetails.username,
password: connDetails.password,
privateKey: connDetails.privateKey,
passphrase: connDetails.passphrase,
readyTimeout: timeout,
keepaliveInterval: 5000, // 修改:每 5 秒发送一次 keepalive
keepaliveCountMax: 10, // 修改:最多尝试 10 次 (总超时约 10*5=50 秒)
};
const readyHandler = async () => { // 改为 async 函数
console.log(`SshService: SSH 连接到 ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id}) 成功。`);
sshClient.removeListener('error', errorHandler); // 成功后移除错误监听器
try {
const currentTimeSeconds = Math.floor(Date.now() / 1000);
await ConnectionRepository.updateLastConnected(connDetails.id, currentTimeSeconds);
console.log(`SshService: 已更新连接 ${connDetails.id} 的 last_connected_at 为 ${currentTimeSeconds}`);
} catch (updateError) {
// 更新失败不应阻止连接成功,但需要记录错误
console.error(`SshService: 更新连接 ${connDetails.id} 的 last_connected_at 失败:`, updateError);
}
resolve(sshClient); // 返回 Client 实例
};
const errorHandler = (err: Error) => {
// Ensure this handler only runs once effectively
sshClient.removeListener('ready', readyHandler);
sshClient.removeListener('error', errorHandler); // Remove itself
sshClient.removeListener('close', closeHandler); // Remove close handler if attached
console.error(`SshService: SSH 连接到 ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id}) 失败:`, err);
// Try ending the client gracefully, but don't wait for it if it hangs
try {
sshClient.end();
} catch (endError) {
console.error(`SshService: Error while calling sshClient.end() during error handling:`, endError);
}
reject(err); // Reject the promise
};
const closeHandler = () => {
// Handle unexpected close events if needed, or just ensure listeners are removed
sshClient.removeListener('ready', readyHandler);
sshClient.removeListener('error', errorHandler);
sshClient.removeListener('close', closeHandler);
// console.log(`SshService: SSH connection closed unexpectedly during connection phase for ${connDetails.id}`);
// Avoid rejecting here, let the 'error' handler manage rejection on failure.
};
// Modify readyHandler to remove error and close listeners
const originalReadyHandler = readyHandler; // Keep original logic
const enhancedReadyHandler = async () => {
sshClient.removeListener('error', errorHandler); // Remove error listener on success
sshClient.removeListener('close', closeHandler); // Remove close listener on success
await originalReadyHandler(); // Execute original logic (updates DB, resolves promise)
};
sshClient.once('ready', enhancedReadyHandler); // Use enhanced handler
sshClient.on('error', errorHandler); // Use 'on' but handler removes itself
sshClient.on('close', closeHandler); // Add a handler for close events during connection phase
// --- 处理代理 ---
// Make sure the proxy error handling also calls the main errorHandler
const handleProxyError = (proxyError: Error) => {
console.error(`SshService: Proxy setup failed for ${connDetails.host}:${connDetails.port} (ID: ${connDetails.id})`, proxyError);
// Call the main error handler to ensure consistent cleanup and rejection
errorHandler(proxyError);
};
if (connDetails.proxy) {
const proxy = connDetails.proxy;
console.log(`SshService: 应用代理 ${proxy.name} (${proxy.type}) 连接到 ${connDetails.host}:${connDetails.port}`);
if (proxy.type === 'SOCKS5') {
const socksOptions: SocksClientOptions = {
proxy: { host: proxy.host, port: proxy.port, type: 5, userId: proxy.username, password: proxy.password },
command: 'connect',
destination: { host: connectConfig.host!, port: connectConfig.port! },
timeout: connectConfig.readyTimeout,
};
SocksClient.createConnection(socksOptions)
.then(({ socket }) => {
console.log(`SshService: SOCKS5 代理连接成功 (目标: ${connDetails.host}:${connDetails.port})。`);
connectConfig.sock = socket;
sshClient.connect(connectConfig);
})
.catch(socksError => {
handleProxyError(new Error(`SOCKS5 代理 ${proxy.host}:${proxy.port} 连接失败: ${socksError.message}`));
});
} else if (proxy.type === 'HTTP') {
console.log(`SshService: 尝试通过 HTTP 代理 ${proxy.host}:${proxy.port} 建立隧道到 ${connDetails.host}:${connDetails.port}...`);
const reqOptions: http.RequestOptions = {
method: 'CONNECT',
host: proxy.host,
port: proxy.port,
path: `${connectConfig.host}:${connectConfig.port}`,
timeout: connectConfig.readyTimeout,
agent: false
};
if (proxy.username) {
const auth = 'Basic ' + Buffer.from(proxy.username + ':' + (proxy.password || '')).toString('base64');
reqOptions.headers = { ...reqOptions.headers, 'Proxy-Authorization': auth, 'Proxy-Connection': 'Keep-Alive', 'Host': `${connectConfig.host}:${connectConfig.port}` };
}
const req = http.request(reqOptions);
req.on('connect', (res, socket, head) => {
if (res.statusCode === 200) {
console.log(`SshService: HTTP 代理隧道建立成功 (目标: ${connDetails.host}:${connDetails.port})。`);
connectConfig.sock = socket;
sshClient.connect(connectConfig);
} else {
socket.destroy();
handleProxyError(new Error(`HTTP 代理 ${proxy.host}:${proxy.port} 连接失败 (状态码: ${res.statusCode})`));
}
});
req.on('error', (err) => {
handleProxyError(new Error(`HTTP 代理 ${proxy.host}:${proxy.port} 请求错误: ${err.message}`));
});
req.on('timeout', () => {
req.destroy();
handleProxyError(new Error(`HTTP 代理 ${proxy.host}:${proxy.port} 连接超时`));
});
req.end();
} else {
handleProxyError(new Error(`不支持的代理类型: ${proxy.type}`));
}
if (connDetails.connection_proxy_setting === 'jump') {
if (connDetails.jump_chain && connDetails.jump_chain.length > 0) {
// Log details of each jump host
connDetails.jump_chain.forEach((hop, index) => {
});
return _establishConnectionViaJumpChainRecursive(
0, // hopIndex
null, // previousStream
connDetails.jump_chain,
connDetails, // finalTargetDetails
[], // activeClients (for cleanup of intermediate hops)
timeout // timeoutPerHop (can be refined if needed per hop)
);
} else {
// 无代理,直接连接
console.log(`SshService: 无代理,直接连接到 ${connDetails.host}:${connDetails.port}`);
sshClient.connect(connectConfig);
console.warn(`SshService: Connection ${connDetails.name} set to 'jump' but jump_chain is MISSING or EMPTY. Attempting direct connection as fallback.`);
return _establishDirectSshConnection(connDetails, timeout);
}
});
} else if (connDetails.connection_proxy_setting === 'proxy') {
if (connDetails.proxy) {
return _establishProxyConnection(connDetails, timeout);
} else {
console.warn(`SshService: Connection ${connDetails.name} set to 'proxy' but proxy details are MISSING. Attempting direct connection as fallback.`);
return _establishDirectSshConnection(connDetails, timeout);
}
} else {
if (connDetails.connection_proxy_setting && connDetails.connection_proxy_setting !== null && connDetails.connection_proxy_setting !== undefined) {
}
return _establishDirectSshConnection(connDetails, timeout);
}
};
/**
* 在已连接的 SSH Client 上打开 Shell 通道
* @param sshClient - 已连接的 SSH Client 实例
@@ -306,23 +635,18 @@ export const openShell = (sshClient: Client): Promise<ClientChannel> => {
export const testConnection = async (connectionId: number): Promise<{ latency: number }> => {
console.log(`SshService: 测试连接 ${connectionId}...`);
let sshClient: Client | null = null;
const startTime = Date.now(); // 开始计时
const startTime = Date.now();
try {
// 1. 获取并解密连接信息
const connDetails = await getConnectionDetails(connectionId);
// 2. 尝试建立连接 (使用较短的测试超时时间)
sshClient = await establishSshConnection(connDetails, TEST_TIMEOUT);
const endTime = Date.now(); // 结束计时
const endTime = Date.now();
const latency = endTime - startTime;
console.log(`SshService: 测试连接 ${connectionId} 成功,延迟: ${latency}ms。`);
return { latency }; // 返回延迟
return { latency };
} catch (error) {
console.error(`SshService: 测试连接 ${connectionId} 失败:`, error);
throw error; // 将错误向上抛出
throw error;
} finally {
// 无论成功失败,都关闭 SSH 客户端
if (sshClient) {
sshClient.end();
console.log(`SshService: 测试连接 ${connectionId} 的客户端已关闭。`);
@@ -333,62 +657,56 @@ export const testConnection = async (connectionId: number): Promise<{ latency: n
/**
* 测试未保存的 SSH 连接信息(包括代理)
* @param connectionConfig - 包含连接参数的对象 (host, port, username, auth_method, password?, private_key?, passphrase?, proxy_id?)
* @param connectionConfig - 包含连接参数的对象
* @returns Promise<{ latency: number }> - 如果连接成功则 resolve 包含延迟的对象,否则 reject
* @throws Error 如果连接失败或配置错误
*/
// Ensure ssh_key_id is part of the input type definition
export const testUnsavedConnection = async (connectionConfig: {
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
password?: string;
private_key?: string; // Keep this for direct input
private_key?: string;
passphrase?: string;
ssh_key_id?: number | null; // Ensure this is present
ssh_key_id?: number | null;
proxy_id?: number | null;
}): Promise<{ latency: number }> => {
console.log(`SshService: 测试未保存的连接到 ${connectionConfig.host}:${connectionConfig.port}...`);
let sshClient: Client | null = null;
const startTime = Date.now(); // 开始计时
const startTime = Date.now();
try {
// 1. 构建临时的 DecryptedConnectionDetails 结构
const tempConnDetails: DecryptedConnectionDetails = {
id: -1, // 临时 ID,不实际使用
name: `Test-${connectionConfig.host}`, // 临时名称
id: -1, // Temporary ID for non-persistent connection
name: `Test-${connectionConfig.host}`,
host: connectionConfig.host,
port: connectionConfig.port,
username: connectionConfig.username,
auth_method: connectionConfig.auth_method,
// Initialize credentials, will be populated based on input
password: undefined,
privateKey: undefined,
passphrase: undefined,
proxy: null, // 稍后填充
proxy: null,
connection_proxy_setting: connectionConfig.proxy_id ? 'proxy' : null,
};
// Populate credentials based on auth method and ssh_key_id presence
if (tempConnDetails.auth_method === 'password') {
tempConnDetails.password = connectionConfig.password;
} else { // auth_method is 'key'
} else {
if (connectionConfig.ssh_key_id) {
// Fetch and decrypt stored key if ssh_key_id is provided
console.log(`SshService: Testing unsaved connection using stored SSH key ID: ${connectionConfig.ssh_key_id}...`);
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(connectionConfig.ssh_key_id); // Use imported SshKeyService
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(connectionConfig.ssh_key_id);
if (!storedKeyDetails) {
throw new Error(`选择的 SSH 密钥 (ID: ${connectionConfig.ssh_key_id}) 未找到。`);
}
tempConnDetails.privateKey = storedKeyDetails.privateKey;
tempConnDetails.passphrase = storedKeyDetails.passphrase;
} else {
// Use direct key input if ssh_key_id is not provided
tempConnDetails.privateKey = connectionConfig.private_key; // Use private_key from input
tempConnDetails.privateKey = connectionConfig.private_key;
tempConnDetails.passphrase = connectionConfig.passphrase;
}
}
// 2. 如果提供了 proxy_id,获取并解密代理信息
if (connectionConfig.proxy_id) {
console.log(`SshService: 测试连接需要获取代理 ${connectionConfig.proxy_id} 的信息...`);
const rawProxyInfo = await ProxyRepository.findProxyById(connectionConfig.proxy_id);
@@ -396,13 +714,11 @@ export const testUnsavedConnection = async (connectionConfig: {
throw new Error(`代理 ID ${connectionConfig.proxy_id} 未找到。`);
}
try {
// Add null checks for required proxy fields
const proxyName = rawProxyInfo.name ?? (() => { throw new Error(`Proxy ID ${connectionConfig.proxy_id} has null name.`); })();
const proxyType = rawProxyInfo.type ?? (() => { throw new Error(`Proxy ID ${connectionConfig.proxy_id} has null type.`); })();
const proxyHost = rawProxyInfo.host ?? (() => { throw new Error(`Proxy ID ${connectionConfig.proxy_id} has null host.`); })();
const proxyPort = rawProxyInfo.port ?? (() => { throw new Error(`Proxy ID ${connectionConfig.proxy_id} has null port.`); })();
// Ensure proxyType is one of the allowed values
if (proxyType !== 'SOCKS5' && proxyType !== 'HTTP') {
throw new Error(`Proxy ID ${connectionConfig.proxy_id} has invalid type: ${proxyType}`);
}
@@ -416,29 +732,29 @@ export const testUnsavedConnection = async (connectionConfig: {
username: rawProxyInfo.username || undefined,
password: rawProxyInfo.encrypted_password ? decrypt(rawProxyInfo.encrypted_password) : undefined,
};
tempConnDetails.connection_proxy_setting = 'proxy'; // Ensure this is set
console.log(`SshService: 代理 ${connectionConfig.proxy_id} 信息获取并解密成功。`);
} catch (decryptError: any) {
console.error(`SshService: 处理代理 ${connectionConfig.proxy_id} 凭证失败:`, decryptError);
throw new Error(`处理代理凭证失败: ${decryptError.message}`);
}
} else {
tempConnDetails.connection_proxy_setting = null; // Explicitly no proxy
}
// 3. 尝试建立连接 (使用较短的测试超时时间)
sshClient = await establishSshConnection(tempConnDetails, TEST_TIMEOUT);
const endTime = Date.now(); // 结束计时
const endTime = Date.now();
const latency = endTime - startTime;
console.log(`SshService: 测试未保存的连接到 ${connectionConfig.host}:${connectionConfig.port} 成功,延迟: ${latency}ms。`);
return { latency }; // 返回延迟
return { latency };
} catch (error) {
console.error(`SshService: 测试未保存的连接到 ${connectionConfig.host}:${connectionConfig.port} 失败:`, error);
throw error; // 将错误向上抛出
throw error;
} finally {
// 无论成功失败,都关闭 SSH 客户端
if (sshClient) {
sshClient.end();
console.log(`SshService: 测试未保存连接的客户端已关闭。`);
}
}
};
};
+14 -6
View File
@@ -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 {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nexus-terminal/frontend",
"version": "0.7.3",
"version": "0.7.5",
"private": true,
"type": "module",
"scripts": {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, Teleport, nextTick } from 'vue';
import { ref, Teleport, nextTick, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { ConnectionInfo } from '../stores/connections.store'; // Keep ConnectionInfo type
import { useAddConnectionForm } from '../composables/useAddConnectionForm';
@@ -34,10 +34,14 @@ const {
submitButtonText,
proxies,
tags,
connections,
isProxyLoading,
proxyStoreError,
isTagLoading,
tagStoreError,
advancedConnectionMode,
addJumpHost,
removeJumpHost,
handleSubmit,
handleDeleteConnection,
handleTestConnection,
@@ -47,6 +51,10 @@ const {
testButtonText,
} = useAddConnectionForm(props, emit);
const handleAdvancedConnectionModeUpdate = (newMode: 'proxy' | 'jump') => {
advancedConnectionMode.value = newMode;
};
// Tooltip state and refs - Kept in component as it's purely view-related
const showHostTooltip = ref(false);
const hostTooltipStyle = ref({});
@@ -104,14 +112,19 @@ const handleHostIconMouseLeave = () => {
:form-data="formData"
:proxies="proxies"
:tags="tags"
:connections="connections"
:is-proxy-loading="isProxyLoading"
:proxy-store-error="proxyStoreError"
:is-tag-loading="isTagLoading"
:tag-store-error="tagStoreError"
:advanced-connection-mode="advancedConnectionMode"
@update:advancedConnectionMode="handleAdvancedConnectionModeUpdate"
:add-jump-host="addJumpHost"
:remove-jump-host="removeJumpHost"
@create-tag="handleCreateTag"
@delete-tag="handleDeleteTag"
/>
</template> <!-- End of v-if="!isScriptModeActive" -->
</template>
<!-- Script Mode Section Toggle -->
<div v-if="!isEditMode" class="space-y-4 p-4 border border-border rounded-md bg-header/30 mt-6">
@@ -1,33 +1,48 @@
<script setup lang="ts">
import { computed, watch, type Ref, type PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import TagInput from './TagInput.vue'; // Assuming TagInput is used here
import type { ProxyInfo } from '../stores/proxies.store'; // Corrected Proxy to ProxyInfo
import type { TagInfo } from '../stores/tags.store'; // Corrected Tag to TagInfo
import TagInput from './TagInput.vue';
import type { ProxyInfo } from '../stores/proxies.store';
import type { TagInfo } from '../stores/tags.store';
import type { ConnectionInfo } from '../stores/connections.store';
// Define Props.
const props = defineProps<{
// Define Props
const props = defineProps({
formData: {
type: 'SSH' | 'RDP' | 'VNC'; // Needed to conditionally show proxy selector
proxy_id: number | null;
tag_ids: number[];
notes: string;
};
proxies: ProxyInfo[]; // List of available proxies
tags: TagInfo[]; // List of available tags
isProxyLoading: boolean;
proxyStoreError: string | null;
isTagLoading: boolean;
tagStoreError: string | null;
}>();
type: Object as PropType<{
id?: number;
type: 'SSH' | 'RDP' | 'VNC';
proxy_id: number | null;
jump_chain: Array<number | null> | null;
proxy_type?: 'proxy' | 'jump' | null;
tag_ids: number[];
notes: string;
}>,
required: true
},
proxies: { type: Array as PropType<ProxyInfo[]>, required: true },
connections: { type: Array as PropType<ConnectionInfo[]>, required: true },
tags: { type: Array as PropType<TagInfo[]>, required: true },
isProxyLoading: { type: Boolean, required: true },
proxyStoreError: { type: String as PropType<string | null>, required: false, default: null },
isTagLoading: { type: Boolean, required: true },
tagStoreError: { type: String as PropType<string | null>, required: false, default: null },
advancedConnectionMode: { type: String as PropType<'proxy' | 'jump'>, required: true },
addJumpHost: { type: Function as PropType<() => void>, required: true },
removeJumpHost: { type: Function as PropType<(index: number) => void>, required: true },
isEditMode: { type: Boolean, default: false }
});
// Define Emits for tag creation and deletion
// Define Emits
const emit = defineEmits<{
(e: 'create-tag', tagName: string): void;
(e: 'delete-tag', tagId: number): void;
(e: 'update:advancedConnectionMode', mode: 'proxy' | 'jump'): void;
}>();
const { t } = useI18n();
const handleCreateTagEvent = (tagName: string) => {
emit('create-tag', tagName);
};
@@ -35,6 +50,23 @@ const handleCreateTagEvent = (tagName: string) => {
const handleDeleteTagEvent = (tagId: number) => {
emit('delete-tag', tagId);
};
const setConnectionMode = (mode: 'proxy' | 'jump') => {
if (props.advancedConnectionMode === mode) return; // Access directly
emit('update:advancedConnectionMode', mode);
};
const getAvailableJumpHostsForIndex = (currentIndex: number): ConnectionInfo[] => {
return props.connections.filter(conn => {
if (conn.type !== 'SSH') return false;
if (props.isEditMode && props.formData.id === conn.id) return false;
return !props.formData.jump_chain?.some((jumpHostId, index) => {
return index !== currentIndex && jumpHostId === conn.id;
});
});
};
</script>
<template>
@@ -42,8 +74,31 @@ const handleDeleteTagEvent = (tagId: number) => {
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
<h4 class="text-base font-semibold mb-3 pb-2 border-b border-border/50">{{ t('connections.form.sectionAdvanced', '高级选项') }}</h4>
<!-- Proxy Select - Show only for SSH -->
<!-- Connection Mode Switcher (Only for SSH) -->
<div v-if="props.formData.type === 'SSH'">
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.connectionMode', '连接方式') }}</label>
<div class="flex rounded-md shadow-sm mb-4">
<button
type="button"
@click="setConnectionMode('proxy')"
:class="['flex-1 px-3 py-2 border border-border text-sm font-medium focus:outline-none rounded-l-md',
props.advancedConnectionMode === 'proxy' ? 'bg-primary text-white' : 'bg-background text-foreground hover:bg-border']"
>
{{ t('connections.form.connectionModeProxy', '代理') }}
</button>
<button
type="button"
@click="setConnectionMode('jump')"
:class="['flex-1 px-3 py-2 border-t border-b border-r border-border text-sm font-medium focus:outline-none -ml-px rounded-r-md',
props.advancedConnectionMode === 'jump' ? 'bg-primary text-white' : 'bg-background text-foreground hover:bg-border']"
>
{{ t('connections.form.connectionModeJumpHost', '跳板机') }}
</button>
</div>
</div>
<!-- Proxy Select - Show only for SSH and if 'proxy' mode is selected -->
<div v-if="props.formData.type === 'SSH' && props.advancedConnectionMode === 'proxy'">
<label for="conn-proxy" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.proxy') }} ({{ t('connections.form.optional') }})</label>
<select id="conn-proxy" v-model="props.formData.proxy_id"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
@@ -57,6 +112,39 @@ const handleDeleteTagEvent = (tagId: number) => {
<div v-if="props.proxyStoreError" class="mt-1 text-xs text-error">{{ t('proxies.error', { error: props.proxyStoreError }) }}</div>
</div>
<!-- Jump Host Configuration - Show only for SSH and if 'jump' mode is selected -->
<div v-if="props.formData.type === 'SSH' && props.advancedConnectionMode === 'jump'" class="space-y-3">
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.jumpHostsTitle', '跳板机链配置') }}</label>
<div v-if="!props.formData.jump_chain || props.formData.jump_chain.length === 0" class="text-sm text-muted-foreground italic">
</div>
<template v-if="props.formData.jump_chain">
<div v-for="(jumpHostId, index) in props.formData.jump_chain" :key="index" class="flex items-center space-x-2 p-2 border border-border rounded-md bg-background/50">
<span class="text-sm font-medium text-text-secondary whitespace-nowrap">{{ t('connections.form.jumpHostLabel', '跳板机') }} {{ index + 1 }}:</span>
<select v-model="props.formData.jump_chain[index]"
class="flex-grow px-3 py-1.5 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
<option :value="null">{{ t('connections.form.selectJumpHost', '请选择跳板机') }}</option>
<option v-for="host in getAvailableJumpHostsForIndex(index)" :key="host.id" :value="host.id">
{{ host.name }}
</option>
</select>
<button type="button" @click="props.removeJumpHost(index)"
class="p-1.5 text-destructive hover:text-destructive/80 focus:outline-none focus:ring-1 focus:ring-destructive rounded-md"
:title="t('connections.form.removeJumpHostTitle', '移除此跳板机')">
<svg xmlns="http://www.w3.org/2000/svg" width="1.1em" height="1.1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</template>
<button type="button" @click="props.addJumpHost()"
class="w-full flex items-center justify-center space-x-2 px-3 py-2 border border-dashed border-primary text-primary rounded-md hover:bg-primary/10 focus:outline-none focus:ring-1 focus:ring-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>{{ t('connections.form.addJumpHost', '添加跳板机') }}</span>
</button>
<div v-if="props.connections.filter(c => c.type === 'SSH' && (!props.isEditMode || c.id !== props.formData.id)).length === 0" class="text-xs text-warning-foreground p-2 bg-warning/20 rounded-md">
{{ t('connections.form.noAvailableSshConnectionsForJump', '没有可用的SSH连接作为跳板机请先创建一些SSH连接') }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.tags') }} ({{ t('connections.form.optional') }})</label>
<TagInput
@@ -31,31 +31,35 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
const sshKeysStore = useSshKeysStore();
const uiNotificationsStore = useUiNotificationsStore();
const { isLoading: isConnLoading, error: connStoreError } = storeToRefs(connectionsStore);
const { isLoading: isConnLoading, error: connStoreError, connections } = storeToRefs(connectionsStore);
const { proxies, isLoading: isProxyLoading, error: proxyStoreError } = storeToRefs(proxiesStore);
const { tags, isLoading: isTagLoading, error: tagStoreError } = storeToRefs(tagsStore);
const { sshKeys, isLoading: isSshKeyLoading, error: sshKeyStoreError } = storeToRefs(sshKeysStore);
// 表单数据模型
const initialFormData = {
type: 'SSH' as 'SSH' | 'RDP' | 'VNC',
type: 'SSH' as 'SSH' | 'RDP' | 'VNC',
name: '',
host: '',
port: 22,
username: '',
auth_method: 'password' as 'password' | 'key',
auth_method: 'password' as 'password' | 'key',
password: '',
private_key: '',
passphrase: '',
private_key: '',
passphrase: '',
selected_ssh_key_id: null as number | null,
proxy_id: null as number | null,
tag_ids: [] as number[],
notes: '',
vncPassword: '',
jump_chain: null as Array<any> | null,
proxy_type: null as 'proxy' | 'jump' | null,
tag_ids: [] as number[],
notes: '',
vncPassword: '',
};
const formData = reactive({ ...initialFormData });
const formError = ref<string | null>(null); // 表单级别的错误信息
const advancedConnectionMode = ref<'proxy' | 'jump'>('proxy');
// 合并所有 store 的加载和错误状态
const isLoading = computed(() => isConnLoading.value || isProxyLoading.value || isTagLoading.value || isSshKeyLoading.value); // +++ Include SSH Key loading +++
const storeError = computed(() => connStoreError.value || proxyStoreError.value || tagStoreError.value || sshKeyStoreError.value); // +++ Include SSH Key error +++
@@ -103,6 +107,10 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
formData.username = newVal.username;
formData.auth_method = newVal.auth_method;
formData.proxy_id = newVal.proxy_id ?? null;
formData.proxy_type = newVal.proxy_type ?? null;
formData.jump_chain = newVal.jump_chain ? JSON.parse(JSON.stringify(newVal.jump_chain)) : null;
console.log('[Debug] watch connectionToEdit - newVal.jump_chain:', newVal.jump_chain);
console.log('[Debug] watch connectionToEdit - formData.jump_chain initialized:', formData.jump_chain);
formData.notes = newVal.notes ?? '';
formData.tag_ids = newVal.tag_ids ? [...newVal.tag_ids] : [];
@@ -112,13 +120,24 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
formData.selected_ssh_key_id = null;
}
if (newVal.proxy_type === 'jump' && newVal.jump_chain && newVal.jump_chain.length > 0) {
advancedConnectionMode.value = 'jump';
} else if (newVal.proxy_type === 'proxy' && newVal.proxy_id !== null && newVal.proxy_id !== undefined) {
advancedConnectionMode.value = 'proxy';
}
else if (newVal.jump_chain && newVal.jump_chain.length > 0 && (newVal.proxy_id === null || newVal.proxy_id === undefined)) {
advancedConnectionMode.value = 'jump';
} else {
advancedConnectionMode.value = 'proxy';
}
formData.password = '';
formData.private_key = '';
formData.passphrase = '';
if (newVal.type !== 'VNC') {
formData.vncPassword = '';
} else {
formData.vncPassword = '';
formData.vncPassword = ''; // 保持原逻辑或根据需求调整
}
} else {
Object.assign(formData, initialFormData);
@@ -126,7 +145,11 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
formData.selected_ssh_key_id = null;
formData.notes = '';
formData.vncPassword = '';
}
formData.jump_chain = null;
formData.proxy_type = null;
console.log('[Debug] watch connectionToEdit - formData.jump_chain reset');
advancedConnectionMode.value = 'proxy';
}
}, { immediate: true });
// 组件挂载时获取代理、标签和 SSH 密钥列表
@@ -151,6 +174,22 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
}
});
watch([() => formData.type, advancedConnectionMode], ([newType, newAdvMode], [oldType, oldAdvMode]) => {
if (newType === 'SSH') {
if (newAdvMode === 'proxy') {
formData.proxy_type = 'proxy';
} else if (newAdvMode === 'jump') {
formData.proxy_type = 'jump';
} else {
formData.proxy_type = null;
}
} else {
formData.proxy_type = null;
}
console.log(`[Debug] useAddConnectionForm: proxy_type set to ${formData.proxy_type} (type: ${newType}, mode: ${newAdvMode})`);
}, { immediate: true });
// Helper function to parse IP range
const parseIpRange = (ipRangeStr: string): string[] | { error: string } => {
if (!ipRangeStr.includes('~')) {
@@ -615,6 +654,7 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
notes: formData.notes,
proxy_id: formData.proxy_id || null,
tag_ids: currentSelectedValidTagIds,
proxy_type: formData.proxy_type,
};
if (formData.type === 'SSH') {
@@ -678,7 +718,9 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
notes: formData.notes,
username: formData.username,
proxy_id: formData.proxy_id || null,
proxy_type: formData.proxy_type,
tag_ids: currentSelectedValidTagIds,
jump_chain: formData.jump_chain ? JSON.parse(JSON.stringify(formData.jump_chain)) : null,
};
if (formData.type === 'SSH') {
@@ -839,6 +881,20 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
return t('connections.form.testConnection');
});
// --- Jump Host Chain Management ---
const addJumpHost = () => {
if (formData.jump_chain === null || formData.jump_chain === undefined) {
formData.jump_chain = [];
}
formData.jump_chain.push(null);
};
const removeJumpHost = (index: number) => {
if (formData.jump_chain && index >= 0 && index < formData.jump_chain.length) {
formData.jump_chain.splice(index, 1);
}
};
return {
formData,
isLoading,
@@ -863,7 +919,9 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
handleDeleteTag,
latencyColor,
testButtonText,
// Expose stores if child components or template parts need direct access, though usually not.
// uiNotificationsStore, // Used internally, not needed to be returned
advancedConnectionMode,
addJumpHost,
removeJumpHost,
connections,
};
}
+11 -1
View File
@@ -197,6 +197,9 @@
"tags": "Tags:",
"notes": "Notes:",
"notesPlaceholder": "Enter connection notes...",
"connectionMode": "Proxy Type:",
"connectionModeProxy": "Proxy Server",
"connectionModeJumpHost": "Jump Host",
"connectionType": "Connection Type:",
"typeSsh": "SSH",
"typeRdp": "RDP",
@@ -259,7 +262,14 @@
"scriptErrorInvalidUserHostPortFormat": "Invalid format for '{part}', expected format is 'user@host' or 'user@host:port'",
"scriptTagCreated": "Tag '{tagName}' created",
"scriptErrorTagCreationFailed": "Failed to create tag '{tagName}'",
"scriptModeAddingConnections": "Adding {count} connections via script mode..."
"scriptModeAddingConnections": "Adding {count} connections in script mode...",
"jumpHostsTitle": "Jump Host Chain Configuration",
"jumpHostLabel": "Jump Host",
"selectJumpHost": "Please select a jump host",
"removeJumpHostTitle": "Remove this jump host",
"addJumpHost": "Add Jump Host",
"noAvailableSshConnectionsForJump": "No available SSH connections for jump host. Please create some SSH connections first."
},
"test": {
"success": "Connection test successful!",
+12 -2
View File
@@ -176,7 +176,10 @@
"tags": "タグ:",
"notes": "備考:",
"notesPlaceholder": "接続に関する備考を入力してください...",
"testConnection": "接続をテスト",
"connectionMode": "プロキシタイプ:",
"connectionModeProxy": "プロキシサーバー",
"connectionModeJumpHost": "踏み台サーバー",
"testConnection": "接続をテスト",
"testing": "テスト中...",
"title": "新しい接続を追加",
"titleEdit": "接続の編集",
@@ -239,7 +242,14 @@
"scriptErrorInvalidUserHostPortFormat": "'{part}' の形式が無効です、期待される形式は 'user@host' または 'user@host:port' です",
"scriptTagCreated": "タグ '{tagName}' が作成されました",
"scriptErrorTagCreationFailed": "タグ '{tagName}' の作成に失敗しました",
"scriptModeAddingConnections": "スクリプトモードで {count} の接続を追加しています..."
"scriptModeAddingConnections": "スクリプトモードで {count} の接続を追加...",
"jumpHostsTitle": "ジャンプホストチェーン設定",
"jumpHostLabel": "ジャンプホスト",
"selectJumpHost": "ジャンプホストを選択してください",
"removeJumpHostTitle": "このジャンプホストを削除",
"addJumpHost": "ジャンプホストを追加",
"noAvailableSshConnectionsForJump": "ジャンプホストとして使用できるSSH接続がありません。先にSSH接続を作成してください。"
},
"noConnections": "接続がありません。'新しい接続を追加'をクリックして作成してください。",
"noUntaggedConnections": "タグなしの接続はありません。",
+10 -2
View File
@@ -1,5 +1,4 @@
{
"appName": "星枢终端",
"projectName": "星枢终端",
"slogan": "星垂平野阔,枢动万端通",
@@ -196,6 +195,9 @@
"tags": "标签:",
"notes": "备注:",
"notesPlaceholder": "输入连接备注...",
"connectionMode": "代理类型:",
"connectionModeProxy": "代理服务器",
"connectionModeJumpHost": "跳板机",
"connectionType": "连接类型",
"typeSsh": "SSH",
"typeRdp": "RDP",
@@ -259,7 +261,13 @@
"scriptErrorInvalidUserHostPortFormat": "'{part}' 部分格式无效,期望格式为 'user@host' 或 'user@host:port'",
"scriptTagCreated": "标签 '{tagName}' 已创建",
"scriptErrorTagCreationFailed": "创建标签 '{tagName}' 失败",
"scriptModeAddingConnections": "正在通过脚本模式添加 {count} 个连接..."
"scriptModeAddingConnections": "正在通过脚本模式添加 {count} 个连接...",
"jumpHostsTitle": "跳板机链配置",
"jumpHostLabel": "跳板机",
"selectJumpHost": "请选择跳板机",
"removeJumpHostTitle": "移除此跳板机",
"addJumpHost": "添加跳板机",
"noAvailableSshConnectionsForJump": "没有可用的SSH连接作为跳板机。请先创建一些SSH连接。"
},
"test": {
"success": "连接测试成功!",
@@ -11,13 +11,15 @@ export interface ConnectionInfo {
username: string;
auth_method: 'password' | 'key';
proxy_id?: number | null; // 关联的代理 ID (可选)
proxy_type?: 'proxy' | 'jump' | null;
tag_ids?: number[]; // 关联的标签 ID 数组 (可选)
ssh_key_id?: number | null; // +++ 关联的 SSH 密钥 ID (可选) +++
created_at: number;
updated_at: number;
last_connected_at: number | null;
notes?: string | null;
notes?: string | null;
vncPassword?: string; // VNC specific password
jump_chain?: number[] | null;
}
// 定义 Store State 的接口
@@ -98,7 +100,9 @@ export const useConnectionsStore = defineStore('connections', {
passphrase?: string; // SSH specific
vncPassword?: string; // VNC specific password
proxy_id?: number | null;
proxy_type?: 'proxy' | 'jump' | null;
tag_ids?: number[]; // 允许传入 tag_ids
jump_chain?: number[] | null;
}) {
this.isLoading = true;
this.error = null;
@@ -125,7 +129,7 @@ export const useConnectionsStore = defineStore('connections', {
// 更新连接 Action
// 更新参数类型以包含 proxy_id 和 tag_ids
// Update parameter type to include 'type' and VNC fields
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { type?: 'SSH' | 'RDP' | 'VNC'; password?: string; private_key?: string; passphrase?: string; vncPassword?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { type?: 'SSH' | 'RDP' | 'VNC'; password?: string; private_key?: string; passphrase?: string; vncPassword?: string; proxy_id?: number | null; proxy_type?: 'proxy' | 'jump' | null; tag_ids?: number[]; jump_chain?: number[] | null; }>) {
this.isLoading = true;
this.error = null;
try {