From 099a94f358eea579a879f5c3aa8336f7d65e7180 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Tue, 15 Apr 2025 09:03:44 +0800 Subject: [PATCH] update --- .gitignore | 1 + .../src/connections/connections.controller.ts | 1096 ++--------------- .../backend/src/proxies/proxies.controller.ts | 224 +--- .../src/repositories/connection.repository.ts | 310 +++++ .../src/repositories/proxy.repository.ts | 153 +++ .../src/repositories/tag.repository.ts | 104 ++ .../src/services/connection.service.ts | 214 ++++ .../src/services/import-export.service.ts | 291 +++++ .../backend/src/services/proxy.service.ts | 174 +++ packages/backend/src/services/sftp.service.ts | 186 +++ packages/backend/src/services/ssh.service.ts | 433 +++++++ packages/backend/src/services/tag.service.ts | 79 ++ packages/backend/src/tags/tags.controller.ts | 154 +-- 13 files changed, 2152 insertions(+), 1267 deletions(-) create mode 100644 packages/backend/src/repositories/connection.repository.ts create mode 100644 packages/backend/src/repositories/proxy.repository.ts create mode 100644 packages/backend/src/repositories/tag.repository.ts create mode 100644 packages/backend/src/services/connection.service.ts create mode 100644 packages/backend/src/services/import-export.service.ts create mode 100644 packages/backend/src/services/proxy.service.ts create mode 100644 packages/backend/src/services/sftp.service.ts create mode 100644 packages/backend/src/services/ssh.service.ts create mode 100644 packages/backend/src/services/tag.service.ts diff --git a/.gitignore b/.gitignore index ae4ecd0..297a4ae 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,4 @@ dist /doc *.db /packages/data +*.db diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index fa57941..a17fe5d 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -1,136 +1,48 @@ import { Request, Response } from 'express'; -import { Statement } from 'sqlite3'; // 引入 Statement 类型 -import { getDb } from '../database'; -import { encrypt, decrypt } from '../utils/crypto'; // 引入加解密函数 +// Removed duplicate import +import * as ConnectionService from '../services/connection.service'; +import * as SshService from '../services/ssh.service'; // 引入 SshService +import * as ImportExportService from '../services/import-export.service'; // 引入 ImportExportService -const db = getDb(); +// --- 移除所有不再需要的导入和变量 --- +// import { Statement } from 'sqlite3'; +// import { getDb } from '../database'; +// const db = getDb(); +// --- 清理结束 --- -// 连接数据结构 (用于类型提示,不包含敏感信息) -interface ConnectionInfoBase { - id: number; - name: string; - host: string; - port: number; - username: string; - auth_method: 'password' | 'key'; // 支持密码或密钥 - // proxy_id: number | null; // 待添加代理支持 - created_at: number; - updated_at: number; - last_connected_at: number | null; -} /** * 创建新连接 (POST /api/v1/connections) */ export const createConnection = async (req: Request, res: Response): Promise => { - // 新增 proxy_id 和 tag_ids - const { name, host, port = 22, username, auth_method, password, private_key, passphrase, proxy_id, tag_ids } = req.body; - const userId = req.session.userId; // 从会话获取用户 ID - - // 输入验证 (基础) - if (!name || !host || !username || !auth_method) { - res.status(400).json({ message: '缺少必要的连接信息 (name, host, username, auth_method)。' }); - return; - } - if (auth_method === 'password' && !password) { - res.status(400).json({ message: '密码认证方式需要提供 password。' }); - return; - } - if (auth_method === 'key' && !private_key) { - res.status(400).json({ message: '密钥认证方式需要提供 private_key。' }); - return; - } - if (auth_method !== 'password' && auth_method !== 'key') { - res.status(400).json({ message: '无效的认证方式 (auth_method),必须是 "password" 或 "key"。' }); - return; - } - if (typeof port !== 'number' || port <= 0 || port > 65535) { - res.status(400).json({ message: '端口号无效。' }); - return; - } - try { - let encryptedPassword = null; - let encryptedPrivateKey = null; - let encryptedPassphrase = null; - - if (auth_method === 'password') { - encryptedPassword = encrypt(password); - } else if (auth_method === 'key') { - encryptedPrivateKey = encrypt(private_key); - if (passphrase) { - encryptedPassphrase = encrypt(passphrase); - } + // 基本输入验证(更复杂的验证可以在服务层或使用中间件) + const { name, host, username, auth_method, password, private_key } = req.body; + if (!name || !host || !username || !auth_method) { + res.status(400).json({ message: '缺少必要的连接信息 (name, host, username, auth_method)。' }); + return; + } + if (auth_method === 'password' && !password) { + res.status(400).json({ message: '密码认证方式需要提供 password。' }); + return; + } + if (auth_method === 'key' && !private_key) { + res.status(400).json({ message: '密钥认证方式需要提供 private_key。' }); + return; } - const now = Math.floor(Date.now() / 1000); // 当前 Unix 时间戳 (秒) - - // 插入数据库 - const result = await new Promise<{ lastID: number }>((resolve, reject) => { - const stmt = db.prepare( - `INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` // 添加 proxy_id - ); - // 注意:这里没有存储 userId,因为 MVP 只有一个用户。如果未来支持多用户,需要添加 user_id 字段。 - stmt.run( - name, host, port, username, auth_method, - encryptedPassword, encryptedPrivateKey, encryptedPassphrase, - proxy_id ?? null, // 如果未提供则设为 null - now, now, - function (this: Statement, err: Error | null) { - if (err) { - console.error('插入连接时出错:', err.message); - return reject(new Error('创建连接失败')); - } - resolve({ lastID: (this as any).lastID }); - } - ); - stmt.finalize(); // 完成语句执行 - }); - - const newConnectionId = result.lastID; - - // 处理标签关联 - if (Array.isArray(tag_ids) && tag_ids.length > 0) { - const insertTagStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`); - // 使用事务确保原子性 - db.serialize(() => { - db.run('BEGIN TRANSACTION'); - try { - tag_ids.forEach((tagId: any) => { - if (typeof tagId === 'number' && tagId > 0) { - insertTagStmt.run(newConnectionId, tagId); - } else { - console.warn(`创建连接 ${newConnectionId} 时,提供的 tag_id 无效: ${tagId}`); - } - }); - db.run('COMMIT'); - } catch (tagError: any) { - console.error(`为连接 ${newConnectionId} 添加标签时出错:`, tagError); - db.run('ROLLBACK'); // 出错时回滚 - // 可以选择抛出错误或仅记录警告 - // throw new Error('处理标签关联失败'); - } finally { - insertTagStmt.finalize(); - } - }); - } - - // 返回成功响应 (包含 proxy_id 和 tag_ids) - res.status(201).json({ - message: '连接创建成功。', - connection: { - id: newConnectionId, - name, host, port, username, auth_method, - proxy_id: proxy_id ?? null, - tag_ids: Array.isArray(tag_ids) ? tag_ids.filter(id => typeof id === 'number' && id > 0) : [], // 返回有效的 tag_ids - created_at: now, updated_at: now, last_connected_at: null - } - }); + // 将请求体传递给服务层处理 + const newConnection = await ConnectionService.createConnection(req.body); + res.status(201).json({ message: '连接创建成功。', connection: newConnection }); } catch (error: any) { - console.error('创建连接时发生错误:', error); - res.status(500).json({ message: error.message || '创建连接时发生内部服务器错误。' }); + console.error('Controller: 创建连接时发生错误:', error); + // 根据错误类型返回不同的状态码,例如验证错误返回 400 + if (error.message.includes('缺少') || error.message.includes('需要提供')) { + res.status(400).json({ message: error.message }); + } else { + res.status(500).json({ message: error.message || '创建连接时发生内部服务器错误。' }); + } } }; @@ -138,39 +50,11 @@ export const createConnection = async (req: Request, res: Response): Promise => { - const userId = req.session.userId; // 虽然 MVP 只有一个用户,但保留以备将来使用 - try { - // 更新查询以包含关联的标签 ID (使用 GROUP_CONCAT) - const connections = await new Promise<(ConnectionInfoBase & { proxy_id: number | null, tag_ids: number[] })[]>((resolve, reject) => { - db.all( - `SELECT - c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id, - c.created_at, c.updated_at, c.last_connected_at, - GROUP_CONCAT(ct.tag_id) as tag_ids_str - FROM connections c - LEFT JOIN connection_tags ct ON c.id = ct.connection_id - GROUP BY c.id - ORDER BY c.name ASC`, - (err, rows: any[]) => { // 使用 any[] 因为 tag_ids_str 是字符串 - if (err) { - console.error('查询连接列表时出错:', err.message); - return reject(new Error('获取连接列表失败')); - } - // 处理 tag_ids_str,将其转换为数字数组 - const processedRows = rows.map(row => ({ - ...row, - tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number) : [] - })); - resolve(processedRows); - } - ); - }); - + const connections = await ConnectionService.getAllConnections(); res.status(200).json(connections); - } catch (error: any) { - console.error('获取连接列表时发生错误:', error); + console.error('Controller: 获取连接列表时发生错误:', error); res.status(500).json({ message: error.message || '获取连接列表时发生内部服务器错误。' }); } }; @@ -179,50 +63,22 @@ export const getConnections = async (req: Request, res: Response): Promise * 获取单个连接信息 (GET /api/v1/connections/:id) */ export const getConnectionById = async (req: Request, res: Response): Promise => { - const connectionId = parseInt(req.params.id, 10); - const userId = req.session.userId; - - if (isNaN(connectionId)) { - res.status(400).json({ message: '无效的连接 ID。' }); - return; - } - try { - // 更新查询以包含关联的标签 ID (使用 GROUP_CONCAT) - const connection = await new Promise<(ConnectionInfoBase & { proxy_id: number | null, tag_ids: number[] }) | null>((resolve, reject) => { - db.get( - `SELECT - c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id, - c.created_at, c.updated_at, c.last_connected_at, - GROUP_CONCAT(ct.tag_id) as tag_ids_str - FROM connections c - LEFT JOIN connection_tags ct ON c.id = ct.connection_id - WHERE c.id = ? - GROUP BY c.id`, // GROUP BY 仍然需要,即使只有一行 - [connectionId], - (err, row: any) => { // 使用 any[] 因为 tag_ids_str 是字符串 - if (err) { - console.error(`查询连接 ${connectionId} 时出错:`, err.message); - return reject(new Error('获取连接信息失败')); - } - if (row) { - // 处理 tag_ids_str - row.tag_ids = row.tag_ids_str ? row.tag_ids_str.split(',').map(Number) : []; - delete row.tag_ids_str; // 移除临时字段 - } - resolve(row || null); - } - ); - }); + const connectionId = parseInt(req.params.id, 10); + if (isNaN(connectionId)) { + res.status(400).json({ message: '无效的连接 ID。' }); + return; + } + + const connection = await ConnectionService.getConnectionById(connectionId); if (!connection) { res.status(404).json({ message: '连接未找到。' }); } else { res.status(200).json(connection); } - } catch (error: any) { - console.error(`获取连接 ${connectionId} 时发生错误:`, error); + console.error(`Controller: 获取连接 ${req.params.id} 时发生错误:`, error); res.status(500).json({ message: error.message || '获取连接信息时发生内部服务器错误。' }); } }; @@ -231,237 +87,36 @@ export const getConnectionById = async (req: Request, res: Response): Promise => { - const connectionId = parseInt(req.params.id, 10); - // 新增 proxy_id 和 tag_ids - const { name, host, port, username, auth_method, password, private_key, passphrase, proxy_id, tag_ids } = req.body; - const userId = req.session.userId; - - if (isNaN(connectionId)) { - res.status(400).json({ message: '无效的连接 ID。' }); - return; - } - - // 输入验证 (与创建类似,但允许部分更新) - // 更新验证逻辑以包含 proxy_id - if (!name && !host && port === undefined && !username && !auth_method && !password && !private_key && passphrase === undefined && proxy_id === undefined) { - res.status(400).json({ message: '没有提供要更新的字段。' }); - return; - } - if (auth_method && auth_method !== 'password' && auth_method !== 'key') { - res.status(400).json({ message: '无效的认证方式 (auth_method),必须是 "password" 或 "key"。' }); - return; - } - // 如果提供了 auth_method,需要确保对应的凭证也提供了或已存在 - // (更复杂的验证逻辑可能需要先查询现有记录) - 现在实现它 - try { - // 1. 先查询当前的连接信息 - const currentConnection = await new Promise<(ConnectionInfoBase & { encrypted_password?: string | null, encrypted_private_key?: string | null, encrypted_passphrase?: string | null, proxy_id?: number | null }) | null>((resolve, reject) => { - // 注意:需要查询加密字段以进行比较和保留 - db.get( - `SELECT id, name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id - FROM connections - WHERE id = ?`, - [connectionId], - (err, row: any) => { // 使用 any 避免类型冲突,或定义更完整的接口 - if (err) { - console.error(`查询连接 ${connectionId} 时出错:`, err.message); - return reject(new Error('获取连接信息失败')); - } - resolve(row || null); - } - ); - }); - - if (!currentConnection) { - res.status(404).json({ message: '连接未找到。' }); + const connectionId = parseInt(req.params.id, 10); + if (isNaN(connectionId)) { + res.status(400).json({ message: '无效的连接 ID。' }); return; } - const fieldsToUpdate: { [key: string]: any } = {}; - const params: any[] = []; - let newAuthMethod = auth_method || currentConnection.auth_method; // 确定最终的认证方式 - - // 构建要更新的非敏感字段和参数 - if (name !== undefined) { fieldsToUpdate.name = name; params.push(name); } - if (host !== undefined) { fieldsToUpdate.host = host; params.push(host); } - if (port !== undefined) { - if (typeof port !== 'number' || port <= 0 || port > 65535) { - res.status(400).json({ message: '端口号无效。' }); - return; - } - fieldsToUpdate.port = port; params.push(port); - } - if (username !== undefined) { fieldsToUpdate.username = username; params.push(username); } - // 新增:处理 proxy_id 更新 (允许设为 null) - if (proxy_id !== undefined) { fieldsToUpdate.proxy_id = proxy_id; params.push(proxy_id ?? null); } - - // --- 处理认证方式和凭证更新 (重构逻辑) --- - if (auth_method && auth_method !== currentConnection.auth_method) { - // --- Case 1: 认证方式已改变 --- - fieldsToUpdate.auth_method = auth_method; - params.push(auth_method); - - if (auth_method === 'password') { - // 切换到密码认证 - if (!password) { - // 必须提供密码才能切换 - res.status(400).json({ message: '切换到密码认证时需要提供 password。' }); - return; - } - fieldsToUpdate.encrypted_password = encrypt(password); - params.push(fieldsToUpdate.encrypted_password); - // 清除旧的密钥信息 - fieldsToUpdate.encrypted_private_key = null; - params.push(null); - fieldsToUpdate.encrypted_passphrase = null; - params.push(null); - } else { // auth_method === 'key' - // 切换到密钥认证 - if (!private_key) { - // 必须提供私钥才能切换 - res.status(400).json({ message: '切换到密钥认证时需要提供 private_key。' }); - return; - } - fieldsToUpdate.encrypted_private_key = encrypt(private_key); - params.push(fieldsToUpdate.encrypted_private_key); - // 密码短语是可选的 - fieldsToUpdate.encrypted_passphrase = passphrase ? encrypt(passphrase) : null; - params.push(fieldsToUpdate.encrypted_passphrase); - // 清除旧的密码信息 - fieldsToUpdate.encrypted_password = null; - params.push(null); - } - } else { - // --- Case 2: 认证方式未改变 (或请求中未指定 auth_method) --- - // 仅当提供了新的凭证时才更新 - if (currentConnection.auth_method === 'password') { - if (password) { // 如果提供了新密码 - fieldsToUpdate.encrypted_password = encrypt(password); - params.push(fieldsToUpdate.encrypted_password); - } - // 如果没提供新密码,则不更新密码字段,保留旧密码 - } else if (currentConnection.auth_method === 'key') { - if (private_key) { // 如果提供了新私钥 - fieldsToUpdate.encrypted_private_key = encrypt(private_key); - params.push(fieldsToUpdate.encrypted_private_key); - // 如果提供了新私钥,则密码短语也必须一起更新(即使是清空) - fieldsToUpdate.encrypted_passphrase = passphrase ? encrypt(passphrase) : null; - params.push(fieldsToUpdate.encrypted_passphrase); - } else if (passphrase !== undefined) { // 如果只提供了密码短语 (允许清空) - fieldsToUpdate.encrypted_passphrase = passphrase ? encrypt(passphrase) : null; - params.push(fieldsToUpdate.encrypted_passphrase); - } - // 如果私钥和密码短语都未提供,则不更新这两个字段,保留旧值 - } - } - // --- 凭证处理结束 --- - - const now = Math.floor(Date.now() / 1000); - fieldsToUpdate.updated_at = now; - params.push(now); - - const setClauses = Object.keys(fieldsToUpdate).map(key => `${key} = ?`).join(', '); - - if (!setClauses) { - res.status(400).json({ message: '没有有效的字段进行更新。' }); + // 基本验证(可选,服务层也会验证) + const { auth_method, password, private_key } = req.body; + if (auth_method && auth_method !== 'password' && auth_method !== 'key') { + res.status(400).json({ message: '无效的认证方式 (auth_method),必须是 "password" 或 "key"。' }); return; } + // 注意:服务层会处理更复杂的验证,比如切换认证方式时凭证是否提供 - params.push(connectionId); // 添加 WHERE id = ? 的参数 + const updatedConnection = await ConnectionService.updateConnection(connectionId, req.body); - // 更新数据库 - // 注意:如果未来支持多用户,需要添加 AND user_id = ? 条件 - const result = await new Promise<{ changes: number }>((resolve, reject) => { - const stmt = db.prepare( - `UPDATE connections SET ${setClauses} WHERE id = ?` - ); - stmt.run(...params, function (this: Statement, err: Error | null) { - if (err) { - console.error(`更新连接 ${connectionId} 时出错:`, err.message); - return reject(new Error('更新连接失败')); - } - // this.changes 包含受影响的行数 - // 使用类型断言 (as any) 来解决 TS 类型检查问题 - resolve({ changes: (this as any).changes }); - }); - stmt.finalize(); - }); - - if (result.changes === 0) { - res.status(404).json({ message: '连接未找到或未作更改。' }); + if (!updatedConnection) { + res.status(404).json({ message: '连接未找到。' }); } else { - // 获取更新后的信息(不含敏感数据)并返回 - const updatedConnection = await new Promise((resolve, reject) => { - db.get( - // 新增:包含 proxy_id - `SELECT id, name, host, port, username, auth_method, proxy_id, created_at, updated_at, last_connected_at - FROM connections WHERE id = ?`, - [connectionId], - (err, row: ConnectionInfoBase & { proxy_id: number | null }) => err ? reject(err) : resolve(row || null) - ); - }); - - // 处理标签关联更新 - if (tag_ids !== undefined && Array.isArray(tag_ids)) { // 仅当提供了 tag_ids 时才处理 - const deleteStmt = db.prepare(`DELETE FROM connection_tags WHERE connection_id = ?`); - const insertStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`); - - await new Promise((resolve, reject) => { - db.serialize(() => { - db.run('BEGIN TRANSACTION'); - try { - // 1. 删除旧关联 - deleteStmt.run(connectionId, (err: Error | null) => { // 添加 err 类型 - if (err) throw err; // 抛出错误以触发 rollback - }); - deleteStmt.finalize(); // finalize delete statement - - // 2. 插入新关联 (如果 tag_ids 不为空) - if (tag_ids.length > 0) { - tag_ids.forEach((tagId: any) => { - if (typeof tagId === 'number' && tagId > 0) { - insertStmt.run(connectionId, tagId, (err: Error | null) => { // 添加 err 类型 - if (err) throw err; // 抛出错误以触发 rollback - }); - } else { - console.warn(`更新连接 ${connectionId} 时,提供的 tag_id 无效: ${tagId}`); - } - }); - } - insertStmt.finalize(); // finalize insert statement - db.run('COMMIT', (commitErr: Error | null) => { // 添加 commitErr 类型 - if (commitErr) throw commitErr; - resolve(); // 事务成功 - }); - } catch (tagError: any) { - console.error(`更新连接 ${connectionId} 的标签关联时出错:`, tagError); - db.run('ROLLBACK'); - // 将标签处理错误附加到主错误或单独处理 - reject(new Error('处理标签关联失败')); - } - }); - }); - } // 结束标签处理 - - // 在返回的 updatedConnection 中添加 tag_ids - if (updatedConnection) { - // 查询最新的 tag_ids - const currentTagIds = await new Promise((resolve, reject) => { - db.all('SELECT tag_id FROM connection_tags WHERE connection_id = ?', [connectionId], (err: Error | null, rows: { tag_id: number }[]) => { // 添加 err 类型 - if (err) return reject(err); - resolve(rows.map(r => r.tag_id)); - }); - }); - (updatedConnection as any).tag_ids = currentTagIds; // 添加 tag_ids 字段 - } - - res.status(200).json({ message: '连接更新成功。', connection: updatedConnection }); + res.status(200).json({ message: '连接更新成功。', connection: updatedConnection }); } - } catch (error: any) { - console.error(`更新连接 ${connectionId} 时发生错误:`, error); - res.status(500).json({ message: error.message || '更新连接时发生内部服务器错误。' }); + console.error(`Controller: 更新连接 ${req.params.id} 时发生错误:`, error); + // 根据错误类型返回不同的状态码 + if (error.message.includes('需要提供')) { + res.status(400).json({ message: error.message }); + } else { + res.status(500).json({ message: error.message || '更新连接时发生内部服务器错误。' }); + } } }; @@ -469,626 +124,109 @@ export const updateConnection = async (req: Request, res: Response): Promise => { - const connectionId = parseInt(req.params.id, 10); - const userId = req.session.userId; - - if (isNaN(connectionId)) { - res.status(400).json({ message: '无效的连接 ID。' }); - return; - } - try { - // 删除数据库中的记录 - // 注意:如果未来支持多用户,需要添加 AND user_id = ? 条件 - const result = await new Promise<{ changes: number }>((resolve, reject) => { - const stmt = db.prepare( - `DELETE FROM connections WHERE id = ?` - ); - stmt.run(connectionId, function (this: Statement, err: Error | null) { - if (err) { - console.error(`删除连接 ${connectionId} 时出错:`, err.message); - return reject(new Error('删除连接失败')); - } - // this.changes 包含受影响的行数 - resolve({ changes: (this as any).changes }); - }); - stmt.finalize(); - }); - - if (result.changes === 0) { - res.status(404).json({ message: '连接未找到。' }); - } else { - res.status(200).json({ message: '连接删除成功。' }); // 也可以使用 204 No Content + const connectionId = parseInt(req.params.id, 10); + if (isNaN(connectionId)) { + res.status(400).json({ message: '无效的连接 ID。' }); + return; } + const deleted = await ConnectionService.deleteConnection(connectionId); + + if (!deleted) { + res.status(404).json({ message: '连接未找到。' }); + } else { + res.status(200).json({ message: '连接删除成功。' }); // 或使用 204 No Content + } } catch (error: any) { - console.error(`删除连接 ${connectionId} 时发生错误:`, error); + console.error(`Controller: 删除连接 ${req.params.id} 时发生错误:`, error); res.status(500).json({ message: error.message || '删除连接时发生内部服务器错误。' }); } }; - // --- 新增:测试连接功能 --- - import { Client } from 'ssh2'; // 引入 ssh2 Client - import { SocksClient } from 'socks'; // 引入 SOCKS 客户端 - // import { HttpsProxyAgent } from 'https-proxy-agent'; // 不再直接使用 HttpsProxyAgent - import http from 'http'; // 引入 http 用于手动 CONNECT - import net from 'net'; // 引入 net 用于 Socket 类型 - -// 辅助接口:包含解密后的凭证和代理信息 -interface FullConnectionInfo extends ConnectionInfoBase { - password?: string; - privateKey?: string; - passphrase?: string; - proxy?: { - id: number; - name: string; - type: 'SOCKS5' | 'HTTP'; - host: string; - port: number; - username?: string; - password?: string; - } | null; -} - +// --- TODO: 将以下逻辑迁移到 SshService --- /** * 测试连接 (POST /api/v1/connections/:id/test) */ export const testConnection = async (req: Request, res: Response): Promise => { - const connectionId = parseInt(req.params.id, 10); - const userId = req.session.userId; - const TEST_TIMEOUT = 15000; // 测试连接超时时间 (毫秒) - - if (isNaN(connectionId)) { - res.status(400).json({ message: '无效的连接 ID。' }); - return; - } - try { - // 1. 获取完整的连接信息 (包括加密凭证和代理信息) - const connInfo = await new Promise((resolve, reject) => { - // 查询连接信息,并 LEFT JOIN 代理信息 - db.get( - `SELECT - c.*, - p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type, - p.host as proxy_host, p.port as proxy_port, p.username as proxy_username, - p.encrypted_password as proxy_encrypted_password - FROM connections c - LEFT JOIN proxies p ON c.proxy_id = p.id - WHERE c.id = ?`, - [connectionId], - (err, row: any) => { - if (err) { - console.error(`查询连接 ${connectionId} 详细信息时出错:`, err.message); - return reject(new Error('获取连接信息失败')); - } - resolve(row || null); - } - ); - }); - - if (!connInfo) { - res.status(404).json({ message: '连接配置未找到。' }); + const connectionId = parseInt(req.params.id, 10); + if (isNaN(connectionId)) { + res.status(400).json({ message: '无效的连接 ID。' }); return; } - // 2. 构建包含解密凭证和代理对象的 FullConnectionInfo - const fullConnInfo: FullConnectionInfo = { - ...connInfo, // 包含 id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at - proxy: null, // 初始化 proxy - }; + // 调用 SshService 进行连接测试 + await SshService.testConnection(connectionId); - try { - if (connInfo.auth_method === 'password' && connInfo.encrypted_password) { - fullConnInfo.password = decrypt(connInfo.encrypted_password); - } else if (connInfo.auth_method === 'key' && connInfo.encrypted_private_key) { - fullConnInfo.privateKey = decrypt(connInfo.encrypted_private_key); - if (connInfo.encrypted_passphrase) { - fullConnInfo.passphrase = decrypt(connInfo.encrypted_passphrase); - } - } - // 如果凭证解密失败,这里会抛出错误 - - // 处理代理信息 - if (connInfo.proxy_db_id) { - fullConnInfo.proxy = { - id: connInfo.proxy_db_id, - name: connInfo.proxy_name, - type: connInfo.proxy_type, - host: connInfo.proxy_host, - port: connInfo.proxy_port, - username: connInfo.proxy_username || undefined, - password: connInfo.proxy_encrypted_password ? decrypt(connInfo.proxy_encrypted_password) : undefined, - }; - } - } catch (decryptError: any) { - console.error(`处理连接 ${connectionId} 凭证或代理凭证失败:`, decryptError); - res.status(500).json({ success: false, message: `处理凭证失败: ${decryptError.message}` }); - return; - } - - - // 3. 构建 ssh2 连接配置 - let connectConfig: any = { - host: fullConnInfo.host, - port: fullConnInfo.port, - username: fullConnInfo.username, - password: fullConnInfo.password, - privateKey: fullConnInfo.privateKey, - passphrase: fullConnInfo.passphrase, - readyTimeout: TEST_TIMEOUT, // 使用测试超时 - keepaliveInterval: 0, // 测试连接不需要 keepalive - }; - - // 4. 应用代理配置 (复用 websocket.ts 的逻辑,但更健壮) - const sshClient = new Client(); - let connectionPromise: Promise; - - if (fullConnInfo.proxy) { - const proxy = fullConnInfo.proxy; - console.log(`测试连接 ${connectionId}: 应用代理 ${proxy.name} (${proxy.type})`); - if (proxy.type === 'SOCKS5') { - const socksOptions = { - proxy: { - host: proxy.host, - port: proxy.port, - type: 5 as 5, - userId: proxy.username, - password: proxy.password, - }, - command: 'connect' as 'connect', - destination: { - host: fullConnInfo.host, - port: fullConnInfo.port, - }, - timeout: TEST_TIMEOUT, - }; - // SOCKS 连接本身就是一个 Promise - connectionPromise = SocksClient.createConnection(socksOptions) - .then(({ socket }) => { - console.log(`测试连接 ${connectionId}: SOCKS5 代理连接成功`); - connectConfig.sock = socket; - // SSH 连接在 SOCKS 成功后进行 - return new Promise((resolve, reject) => { // 指定 Promise 类型为 void - // 使用 once 可能更符合类型定义 - sshClient.once('ready', resolve).once('error', reject).connect(connectConfig); - }); - }) - .catch(socksError => { - console.error(`测试连接 ${connectionId}: SOCKS5 代理失败:`, socksError); - throw new Error(`SOCKS5 代理连接失败: ${socksError.message}`); // 抛出错误以便捕获 - }); - - } else if (proxy.type === 'HTTP') { - console.log(`测试连接 ${connectionId}: 尝试通过 HTTP 代理 ${proxy.host}:${proxy.port} 建立隧道...`); - // 手动发起 CONNECT 请求 - connectionPromise = new Promise((resolveConnect, rejectConnect) => { - const reqOptions: http.RequestOptions = { - method: 'CONNECT', - host: proxy.host, - port: proxy.port, - path: `${fullConnInfo.host}:${fullConnInfo.port}`, // 目标 SSH 服务器地址和端口 - timeout: TEST_TIMEOUT, - agent: false, // 不使用全局 agent - }; - // 添加代理认证头部 (如果需要) - 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': `${fullConnInfo.host}:${fullConnInfo.port}` // CONNECT 请求的目标 - }; - } - - const req = http.request(reqOptions); - req.on('connect', (res, socket, head) => { - if (res.statusCode === 200) { - console.log(`测试连接 ${connectionId}: HTTP 代理隧道建立成功`); - connectConfig.sock = socket; // 使用建立的隧道 socket - // 在隧道建立后尝试 SSH 连接 - new Promise((resolveSSH, rejectSSH) => { - sshClient.once('ready', resolveSSH).once('error', rejectSSH).connect(connectConfig); - }) - .then(resolveConnect) // SSH 成功则 resolve 外层 Promise - .catch(rejectConnect); // SSH 失败则 reject 外层 Promise - } else { - console.error(`测试连接 ${connectionId}: HTTP 代理 CONNECT 请求失败, 状态码: ${res.statusCode}`); - socket.destroy(); - rejectConnect(new Error(`HTTP 代理连接失败 (状态码: ${res.statusCode})`)); - } - }); - req.on('error', (err) => { - console.error(`测试连接 ${connectionId}: HTTP 代理请求错误:`, err); - rejectConnect(new Error(`HTTP 代理连接错误: ${err.message}`)); - }); - req.on('timeout', () => { - console.error(`测试连接 ${connectionId}: HTTP 代理请求超时`); - req.destroy(); // 销毁请求 - rejectConnect(new Error('HTTP 代理连接超时')); - }); - req.end(); // 发送请求 - }); - } else { - // 未知代理类型 - res.status(400).json({ success: false, message: `不支持的代理类型: ${proxy.type}` }); - return; - } - } else { - // 无代理,直接连接 - connectionPromise = new Promise((resolve, reject) => { // 指定 Promise 类型为 void - // 使用 once 可能更符合类型定义 - sshClient.once('ready', resolve).once('error', reject).connect(connectConfig); - }); - } - - // 5. 执行连接测试并处理结果 - try { - await connectionPromise; - console.log(`测试连接 ${connectionId}: SSH 连接成功`); - res.status(200).json({ success: true, message: '连接测试成功。' }); - } catch (sshError: any) { - console.error(`测试连接 ${connectionId}: SSH 连接失败:`, sshError); - // 尝试提供更具体的错误信息 - let errorMessage = sshError.message || '未知 SSH 错误'; - if (sshError.level === 'client-authentication') { - errorMessage = '认证失败 (用户名、密码或密钥错误)'; - } else if (sshError.code === 'ENOTFOUND' || sshError.code === 'ECONNREFUSED') { - errorMessage = '无法连接到主机或端口'; - } else if (sshError.message.includes('Timed out')) { - errorMessage = `连接超时 (${TEST_TIMEOUT / 1000}秒)`; - } - res.status(500).json({ success: false, message: `连接测试失败: ${errorMessage}` }); - } finally { - // 无论成功失败,都关闭 SSH 客户端 - sshClient.end(); - } + // 如果 SshService.testConnection 没有抛出错误,则表示成功 + res.status(200).json({ success: true, message: '连接测试成功。' }); } catch (error: any) { - console.error(`测试连接 ${connectionId} 时发生内部错误:`, error); - res.status(500).json({ success: false, message: error.message || '测试连接时发生内部服务器错误。' }); - } - }; + console.error(`Controller: 测试连接 ${req.params.id} 时发生错误:`, error); + // SshService 会抛出包含具体原因的 Error + res.status(500).json({ success: false, message: error.message || '测试连接时发生内部服务器错误。' }); + } +}; -// --- 新增:导出连接配置 --- +// --- TODO: 将以下逻辑迁移到 ImportExportService --- /** * 导出所有连接配置 (GET /api/v1/connections/export) */ export const exportConnections = async (req: Request, res: Response): Promise => { - const userId = req.session.userId; // 保留以备将来多用户 - try { - // 1. 查询所有连接及其关联的代理信息 - const connectionsWithProxies = await new Promise((resolve, reject) => { - db.all( - `SELECT - c.id, c.name, c.host, c.port, c.username, c.auth_method, - c.encrypted_password, c.encrypted_private_key, c.encrypted_passphrase, - c.proxy_id, - p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type, - p.host as proxy_host, p.port as proxy_port, p.username as proxy_username, - -- p.auth_method as proxy_auth_method, -- Removed: Column likely doesn't exist based on error - p.encrypted_password as proxy_encrypted_password - -- p.encrypted_private_key as proxy_encrypted_private_key, -- Removed: Column likely doesn't exist - -- p.encrypted_passphrase as proxy_encrypted_passphrase -- Removed: Column likely doesn't exist - FROM connections c - LEFT JOIN proxies p ON c.proxy_id = p.id - ORDER BY c.name ASC`, - (err, rows: any[]) => { - if (err) { - console.error('查询连接和代理信息以供导出时出错:', err.message); - return reject(new Error('导出连接失败:查询连接信息出错')); - } - resolve(rows); - } - ); - }); + const exportedData = await ImportExportService.exportConnections(); - // 2. 查询所有连接的标签信息 - const connectionTags = await new Promise<{[connId: number]: number[]}>((resolve, reject) => { - db.all('SELECT connection_id, tag_id FROM connection_tags', (err, rows: {connection_id: number, tag_id: number}[]) => { - if (err) { - console.error('查询连接标签以供导出时出错:', err.message); - return reject(new Error('导出连接失败:查询标签信息出错')); - } - const tagsMap: {[connId: number]: number[]} = {}; - rows.forEach(row => { - if (!tagsMap[row.connection_id]) { - tagsMap[row.connection_id] = []; - } - tagsMap[row.connection_id].push(row.tag_id); - }); - resolve(tagsMap); - }); - }); - - // 3. 格式化数据以供导出 - const formattedData = connectionsWithProxies.map(row => { - const connection: any = { - // 不导出 id,因为导入时需要重新创建 - name: row.name, - host: row.host, - port: row.port, - username: row.username, - auth_method: row.auth_method, - encrypted_password: row.encrypted_password, - encrypted_private_key: row.encrypted_private_key, - encrypted_passphrase: row.encrypted_passphrase, - tag_ids: connectionTags[row.id] || [], // 从 map 中获取标签 ID - // 不导出 created_at, updated_at, last_connected_at - }; - - // 添加代理信息(如果存在) - if (row.proxy_db_id) { - connection.proxy = { - // 不导出代理的 id,因为导入时可能需要重新创建或匹配 - name: row.proxy_name, - type: row.proxy_type, - host: row.proxy_host, - port: row.proxy_port, - username: row.proxy_username, - // auth_method: row.proxy_auth_method, // Removed - encrypted_password: row.proxy_encrypted_password - // encrypted_private_key: row.proxy_encrypted_private_key, // Removed - // encrypted_passphrase: row.proxy_encrypted_passphrase, // Removed - }; - } else { - connection.proxy = null; // 明确设为 null - } - - return connection; - }); - - // 4. 设置响应头,提示浏览器下载文件 + // 设置响应头,提示浏览器下载文件 const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `nexus-terminal-connections-${timestamp}.json`; res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Type', 'application/json'); - - // 发送 JSON 数据 - res.status(200).json(formattedData); + res.status(200).json(exportedData); } catch (error: any) { - console.error('导出连接时发生错误:', error); - res.status(500).json({ message: error.message || '导出连接时发生内部服务器错误。' }); - } - }; + console.error('Controller: 导出连接时发生错误:', error); + res.status(500).json({ message: error.message || '导出连接时发生内部服务器错误。' }); + } +}; -// --- 新增:导入连接配置 --- +// --- TODO: 将以下逻辑迁移到 ImportExportService (和 ProxyService) --- /** * 导入连接配置 (POST /api/v1/connections/import) */ export const importConnections = async (req: Request, res: Response): Promise => { - const userId = req.session.userId; // 保留以备将来多用户 - if (!req.file) { res.status(400).json({ message: '未找到上传的文件 (需要名为 "connectionsFile" 的文件)。' }); return; } - let importedData: any[]; try { - const fileContent = req.file.buffer.toString('utf8'); - importedData = JSON.parse(fileContent); - if (!Array.isArray(importedData)) { - throw new Error('JSON 文件内容必须是一个数组。'); - } - } catch (error: any) { - res.status(400).json({ message: `解析 JSON 文件失败: ${error.message}` }); - return; - } + const result = await ImportExportService.importConnections(req.file.buffer); - let successCount = 0; - let failureCount = 0; - const errors: { connectionName?: string; message: string }[] = []; - const now = Math.floor(Date.now() / 1000); - - // 准备数据库语句 - const findProxyStmt = db.prepare(`SELECT id FROM proxies WHERE name = ? AND type = ? AND host = ? AND port = ?`); - // 恢复为文档定义的列 - const insertProxyStmt = db.prepare(`INSERT INTO proxies (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); - const insertConnStmt = db.prepare(`INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); - const insertTagStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`); - - try { // Wrap the entire database operation in a try block - await new Promise((resolveOuter, rejectOuter) => { - // 使用事务处理导入 - db.serialize(() => { // Removed async here - db.run('BEGIN TRANSACTION', async (beginErr: Error | null) => { // <--- Added async here - if (beginErr) return rejectOuter(new Error(`开始事务失败: ${beginErr.message}`)); - - try { - // 使用 Promise.allSettled 来处理所有连接的导入 - const importPromises = importedData.map(connData => (async () => { // async IIFE - // 1. 验证基本连接数据结构 - if (!connData.name || !connData.host || !connData.port || !connData.username || !connData.auth_method) { - // failureCount++; // 由 allSettled 结果判断 - // errors.push({ connectionName: connData.name || '未知连接', message: '缺少必要的连接字段 (name, host, port, username, auth_method)。' }); - throw new Error('缺少必要的连接字段 (name, host, port, username, auth_method)。'); - } - // 验证 auth_method 和凭证 - if (connData.auth_method === 'password' && !connData.encrypted_password) { - throw new Error('密码认证缺少 encrypted_password。'); - } - if (connData.auth_method === 'key' && !connData.encrypted_private_key) { - throw new Error('密钥认证缺少 encrypted_private_key。'); - } - - let proxyIdToUse: number | null = null; - - // 2. 处理代理信息(如果存在) - if (connData.proxy) { - const proxyData = connData.proxy; - // 验证代理数据 - if (!proxyData.name || !proxyData.type || !proxyData.host || !proxyData.port /* || !proxyData.auth_method */) { // auth_method 可能不存在,暂时移除强制校验 - throw new Error('代理信息不完整 (缺少 name, type, host, port)。'); - } - // 验证代理凭证存在性 (如果 auth_method 存在) - if (proxyData.auth_method === 'password' && !proxyData.encrypted_password) { - throw new Error('代理密码认证缺少 encrypted_password。'); - } - if (proxyData.auth_method === 'key' && !proxyData.encrypted_private_key) { - throw new Error('代理密钥认证缺少 encrypted_private_key。'); - } - - // 尝试查找现有代理 - const existingProxy = await new Promise<{ id: number } | undefined>((resolve, reject) => { - findProxyStmt.get(proxyData.name, proxyData.type, proxyData.host, proxyData.port, (err: Error | null, row: { id: number } | undefined) => { - if (err) return reject(new Error(`查找代理时出错: ${err.message}`)); - resolve(row); - }); - }); - - if (existingProxy) { - proxyIdToUse = existingProxy.id; - console.log(`导入连接 ${connData.name}: 找到现有代理 ${proxyData.name} (ID: ${proxyIdToUse})`); - } else { - // 代理不存在,创建新代理 - console.log(`导入连接 ${connData.name}: 代理 ${proxyData.name} 不存在,正在创建...`); - const proxyResult = await new Promise<{ lastID: number }>((resolve, reject) => { - insertProxyStmt.run( - proxyData.name, proxyData.type, proxyData.host, proxyData.port, - proxyData.username || null, - // 恢复为文档定义的参数 - proxyData.auth_method || 'none', // 提供默认值 'none' 如果不存在 - proxyData.encrypted_password || null, - proxyData.encrypted_private_key || null, - proxyData.encrypted_passphrase || null, - now, now, - function (this: Statement, err: Error | null) { - if (err) return reject(new Error(`创建代理时出错: ${err.message}`)); - resolve({ lastID: (this as any).lastID }); - } - ); - }); - proxyIdToUse = proxyResult.lastID; - console.log(`导入连接 ${connData.name}: 新代理 ${proxyData.name} 创建成功 (ID: ${proxyIdToUse})`); - } - } // 结束代理处理 - - // 3. 插入连接信息 - const connResult = await new Promise<{ lastID: number }>((resolve, reject) => { - insertConnStmt.run( - connData.name, connData.host, connData.port, connData.username, connData.auth_method, - connData.encrypted_password || null, - connData.encrypted_private_key || null, - connData.encrypted_passphrase || null, - proxyIdToUse, // 使用找到或创建的代理 ID - now, now, - function (this: Statement, err: Error | null) { - if (err) return reject(new Error(`插入连接时出错: ${err.message}`)); - resolve({ lastID: (this as any).lastID }); - } - ); - }); - const newConnectionId = connResult.lastID; - - // 4. 处理标签关联 - if (Array.isArray(connData.tag_ids) && connData.tag_ids.length > 0) { - for (const tagId of connData.tag_ids) { - if (typeof tagId === 'number' && tagId > 0) { - // 注意:这里假设 tagId 在 tags 表中已存在。 - await new Promise((resolve, reject) => { - insertTagStmt.run(newConnectionId, tagId, (err: Error | null) => { - if (err) { - console.warn(`导入连接 ${connData.name}: 关联标签 ID ${tagId} 失败: ${err.message}`); - // 决定是否因此失败 - // reject(new Error(`关联标签 ID ${tagId} 失败: ${err.message}`)); - } - resolve(); // 继续处理下一个标签 - }); - }); - } - } - } - // 如果 IIFE 成功完成,返回 null 或 undefined 表示成功 - return null; - - })().catch(err => { // 捕获 async IIFE 中的错误 - // 返回一个包含错误信息的对象,以便 Promise.allSettled 处理 - return { connectionName: connData.name || '未知连接', error: err }; - })); // 结束 map 和 async IIFE - - // 等待所有导入 Promise 完成 - const results = await Promise.allSettled(importPromises); - - // 处理结果 - results.forEach(result => { - if (result.status === 'fulfilled' && result.value?.error) { - // IIFE 成功执行但内部捕获并返回了错误 - failureCount++; - errors.push({ connectionName: result.value.connectionName, message: result.value.error.message }); - } else if (result.status === 'fulfilled') { - // IIFE 成功执行且没有返回错误 - successCount++; - } else { // status === 'rejected' - IIFE 本身抛出未捕获错误 - failureCount++; - const reason = result.reason as any; - // 尝试获取 connectionName,如果 IIFE 在早期失败可能没有 - const name = importedData[results.indexOf(result)]?.name || '未知连接'; - errors.push({ connectionName: name, message: reason?.message || '未知导入错误' }); - } - }); - - // 根据是否有失败决定提交或回滚 - if (failureCount > 0) { - console.warn(`导入连接存在 ${failureCount} 个错误,正在回滚事务...`); - db.run('ROLLBACK', (rollbackErr: Error | null) => { - if (rollbackErr) console.error("回滚事务失败:", rollbackErr); - // 即使回滚失败,仍需告知前端导入失败 - rejectOuter(new Error(`导入失败,存在 ${failureCount} 个错误。`)); // 使用 rejectOuter 传递错误 - }); - } else { - // 所有记录处理完毕,提交事务 - db.run('COMMIT', (commitErr: Error | null) => { - if (commitErr) { - console.error('提交导入事务时出错:', commitErr); - rejectOuter(new Error(`提交导入事务失败: ${commitErr.message}`)); - } else { - resolveOuter(); // 事务成功,resolve 外层 Promise - } - }); - } - } catch (innerError: any) { - // 捕获 Promise.allSettled 或其他同步错误 - console.error('导入事务内部出错:', innerError); - db.run('ROLLBACK', (rollbackErr: Error | null) => { - if (rollbackErr) console.error("回滚事务失败:", rollbackErr); - rejectOuter(innerError); // 将内部错误传递出去 - }); - } - }); // 结束 BEGIN TRANSACTION 回调 - }); // 结束 db.serialize - }); // 结束 new Promise - - // 如果 Promise 成功 resolve (事务提交成功) - res.status(200).json({ - message: `导入成功完成。共导入 ${successCount} 条连接。`, - successCount, - failureCount: 0 - }); - - } catch (error: any) { // 捕获外层 try 或 rejectOuter 传递的错误 - console.error('导入连接时发生错误:', error); - // 如果错误是由 rejectOuter 传递的,并且包含失败计数,则使用它 - if (failureCount > 0) { - res.status(400).json({ - message: error.message || `导入失败,存在 ${failureCount} 个错误。`, - successCount, - failureCount, - errors + if (result.failureCount > 0) { + // Partial success or complete failure + res.status(400).json({ // Use 400 for partial success with errors + message: `导入完成,但存在 ${result.failureCount} 个错误。成功导入 ${result.successCount} 条。`, + successCount: result.successCount, + failureCount: result.failureCount, + errors: result.errors }); } else { - // 其他错误 (如文件解析、开始事务失败等) + // Complete success + res.status(200).json({ + message: `导入成功完成。共导入 ${result.successCount} 条连接。`, + successCount: result.successCount, + failureCount: 0 + }); + } + } catch (error: any) { + console.error('Controller: 导入连接时发生错误:', error); + // Handle specific errors like JSON parsing error from service + if (error.message.includes('解析 JSON 文件失败')) { + res.status(400).json({ message: error.message }); + } else { res.status(500).json({ message: error.message || '导入连接时发生内部服务器错误。' }); } - } finally { - // Finalize prepared statements regardless of success or failure - // Ensure statements are finalized even if db.serialize wasn't fully entered - findProxyStmt?.finalize(); - insertProxyStmt?.finalize(); - insertConnStmt?.finalize(); - insertTagStmt?.finalize(); } + // No finally block needed here as db statements are handled in service/repo now }; diff --git a/packages/backend/src/proxies/proxies.controller.ts b/packages/backend/src/proxies/proxies.controller.ts index e62a791..4e1b9a8 100644 --- a/packages/backend/src/proxies/proxies.controller.ts +++ b/packages/backend/src/proxies/proxies.controller.ts @@ -1,232 +1,132 @@ import { Request, Response } from 'express'; -import { getDb } from '../database'; -import { encrypt, decrypt } from '../utils/crypto'; // 引入加解密工具 +import * as ProxyService from '../services/proxy.service'; -// 定义代理信息接口 (用于类型提示) -interface ProxyData { - name: string; - type: 'SOCKS5' | 'HTTP'; - host: string; - port: number; - username?: string | null; - password?: string | null; // 接收原始密码 -} +// Helper function to remove sensitive fields for response +const sanitizeProxy = (proxy: ProxyService.ProxyData | null): Partial | null => { + if (!proxy) return null; + const { encrypted_password, encrypted_private_key, encrypted_passphrase, ...sanitized } = proxy; + return sanitized; +}; -// 获取所有代理配置 (不含密码) +// 获取所有代理配置 (不含敏感信息) export const getAllProxies = async (req: Request, res: Response) => { - const db = getDb(); try { - // 查询所有代理,排除 encrypted_password 字段 - const sql = `SELECT id, name, type, host, port, username, created_at, updated_at FROM proxies`; - const proxies = await new Promise((resolve, reject) => { - db.all(sql, [], (err, rows) => { - if (err) { - return reject(err); - } - resolve(rows); - }); - }); - res.status(200).json(proxies); + const proxies = await ProxyService.getAllProxies(); + // Sanitize each proxy before sending + res.status(200).json(proxies.map(sanitizeProxy)); } catch (error: any) { + console.error('Controller: 获取代理列表失败:', error); res.status(500).json({ message: '获取代理列表失败', error: error.message }); } }; -// 获取单个代理配置 (不含密码) +// 获取单个代理配置 (不含敏感信息) export const getProxyById = async (req: Request, res: Response) => { - const db = getDb(); const { id } = req.params; try { - // 查询单个代理,排除 encrypted_password 字段 - const sql = `SELECT id, name, type, host, port, username, created_at, updated_at FROM proxies WHERE id = ?`; - const proxy = await new Promise((resolve, reject) => { - db.get(sql, [id], (err, row) => { - if (err) { - return reject(err); - } - resolve(row); // 如果找不到,row 会是 undefined - }); - }); + const proxyId = parseInt(id, 10); + if (isNaN(proxyId)) { + return res.status(400).json({ message: '无效的代理 ID' }); + } + const proxy = await ProxyService.getProxyById(proxyId); if (proxy) { - res.status(200).json(proxy); + res.status(200).json(sanitizeProxy(proxy)); } else { res.status(404).json({ message: `未找到 ID 为 ${id} 的代理` }); } } catch (error: any) { + console.error(`Controller: 获取代理 ${id} 失败:`, error); res.status(500).json({ message: `获取代理 ${id} 失败`, error: error.message }); } }; // 创建新的代理配置 export const createProxy = async (req: Request, res: Response) => { - const db = getDb(); - const { name, type, host, port, username, password }: ProxyData = req.body; - const now = Math.floor(Date.now() / 1000); // 当前时间戳 (秒) - - // 基本验证 - if (!name || !type || !host || !port) { - return res.status(400).json({ message: '缺少必要的代理信息 (name, type, host, port)' }); - } - if (type !== 'SOCKS5' && type !== 'HTTP') { - return res.status(400).json({ message: '无效的代理类型,仅支持 SOCKS5 或 HTTP' }); - } - try { - let encryptedPassword: string | null = null; - if (password) { - encryptedPassword = encrypt(password); // 加密密码 + // Basic validation (more in service) + const { name, type, host, port } = req.body; + if (!name || !type || !host || !port) { + return res.status(400).json({ message: '缺少必要的代理信息 (name, type, host, port)' }); + } + if (type !== 'SOCKS5' && type !== 'HTTP') { + return res.status(400).json({ message: '无效的代理类型,仅支持 SOCKS5 或 HTTP' }); } - const sql = `INSERT INTO proxies (name, type, host, port, username, encrypted_password, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; - const params = [name, type, host, port, username ?? null, encryptedPassword, now, now]; - - // 使用 Promise 包装 db.run 以便使用 async/await - const result = await new Promise<{ id: number } | null>((resolve, reject) => { - db.run(sql, params, function (err) { // 使用 function 获取 this.lastID - if (err) { - return reject(err); - } - // this.lastID 包含新插入行的 ID - resolve({ id: this.lastID }); - }); + const newProxy = await ProxyService.createProxy(req.body); + res.status(201).json({ + message: '代理创建成功', + proxy: sanitizeProxy(newProxy) // Return sanitized proxy }); - if (result) { - // 返回成功消息和新创建的代理信息 (不含密码) - res.status(201).json({ - message: '代理创建成功', - proxy: { - id: result.id, - name, - type, - host, - port, - username: username ?? null, - created_at: now, - updated_at: now - } - }); - } else { - // 这理论上不应该发生,除非 db.run 内部逻辑问题 - throw new Error('未能获取新创建代理的 ID'); - } - } catch (error: any) { - if (error.message.includes('UNIQUE constraint failed')) { - // 可以添加更具体的唯一约束错误处理,例如判断是哪个字段冲突 + console.error('Controller: 创建代理失败:', error); + if (error.message.includes('UNIQUE constraint failed') || error.message.includes('同名字段冲突')) { return res.status(409).json({ message: '创建代理失败:可能存在同名字段冲突', error: error.message }); } + if (error.message.includes('缺少') || error.message.includes('需要提供')) { + return res.status(400).json({ message: error.message }); + } res.status(500).json({ message: '创建代理失败', error: error.message }); } }; // 更新代理配置 export const updateProxy = async (req: Request, res: Response) => { - const db = getDb(); const { id } = req.params; - const { name, type, host, port, username, password }: Partial = req.body; - const now = Math.floor(Date.now() / 1000); - - // 验证至少有一个字段被更新 - if (!name && !type && !host && port === undefined && username === undefined && password === undefined) { - return res.status(400).json({ message: '没有提供任何要更新的字段' }); - } - if (type && type !== 'SOCKS5' && type !== 'HTTP') { - return res.status(400).json({ message: '无效的代理类型,仅支持 SOCKS5 或 HTTP' }); - } - try { - let encryptedPasswordToUpdate: string | null | undefined = undefined; // undefined 表示不更新密码 - if (password !== undefined) { // 检查 password 字段是否存在于请求体中 - encryptedPasswordToUpdate = password ? encrypt(password) : null; // 如果提供了新密码则加密,如果提供空字符串或 null 则设为 null + const proxyId = parseInt(id, 10); + if (isNaN(proxyId)) { + return res.status(400).json({ message: '无效的代理 ID' }); } - // 构建动态 SQL 更新语句 - const fieldsToUpdate: string[] = []; - const params: any[] = []; - - if (name !== undefined) { fieldsToUpdate.push('name = ?'); params.push(name); } - if (type !== undefined) { fieldsToUpdate.push('type = ?'); params.push(type); } - if (host !== undefined) { fieldsToUpdate.push('host = ?'); params.push(host); } - if (port !== undefined) { fieldsToUpdate.push('port = ?'); params.push(port); } - // username 可以设为 null - if (username !== undefined) { fieldsToUpdate.push('username = ?'); params.push(username ?? null); } - // 只有当 password 在请求体中明确提供了 (包括空字符串或 null),才更新密码字段 - if (encryptedPasswordToUpdate !== undefined) { - fieldsToUpdate.push('encrypted_password = ?'); - params.push(encryptedPasswordToUpdate); + // Basic validation (more in service) + const { name, type, host, port, username, password, auth_method, private_key, passphrase } = req.body; + if (!name && !type && !host && port === undefined && username === undefined && password === undefined && auth_method === undefined && private_key === undefined && passphrase === undefined) { + return res.status(400).json({ message: '没有提供任何要更新的字段' }); + } + if (type && type !== 'SOCKS5' && type !== 'HTTP') { + return res.status(400).json({ message: '无效的代理类型,仅支持 SOCKS5 或 HTTP' }); } - // 总是更新 updated_at 时间戳 - fieldsToUpdate.push('updated_at = ?'); - params.push(now); + const updatedProxy = await ProxyService.updateProxy(proxyId, req.body); - // 添加 WHERE 条件的参数 - params.push(id); - - const sql = `UPDATE proxies SET ${fieldsToUpdate.join(', ')} WHERE id = ?`; - - const result = await new Promise<{ changes: number }>((resolve, reject) => { - db.run(sql, params, function (err) { // 使用 function 获取 this.changes - if (err) { - return reject(err); - } - // this.changes 包含受影响的行数 - resolve({ changes: this.changes }); - }); - }); - - if (result.changes > 0) { - // 更新成功后,获取更新后的代理信息 (不含密码) 并返回 - const updatedProxy = await new Promise((resolve, reject) => { - db.get(`SELECT id, name, type, host, port, username, created_at, updated_at FROM proxies WHERE id = ?`, [id], (err, row) => { - if (err) return reject(err); - resolve(row); - }); - }); - if (updatedProxy) { - res.status(200).json({ message: '代理更新成功', proxy: updatedProxy }); - } else { - // 理论上更新成功后应该能找到,除非并发删除了 - res.status(404).json({ message: `更新成功,但未能找到 ID 为 ${id} 的代理` }); - } + if (updatedProxy) { + res.status(200).json({ message: '代理更新成功', proxy: sanitizeProxy(updatedProxy) }); } else { - // 如果 changes 为 0,说明没有找到对应 ID 的代理 res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行更新` }); } } catch (error: any) { - if (error.message.includes('UNIQUE constraint failed')) { + console.error(`Controller: 更新代理 ${id} 失败:`, error); + if (error.message.includes('UNIQUE constraint failed') || error.message.includes('同名字段冲突')) { return res.status(409).json({ message: '更新代理失败:可能存在同名字段冲突', error: error.message }); } + if (error.message.includes('需要提供')) { + return res.status(400).json({ message: error.message }); + } res.status(500).json({ message: `更新代理 ${id} 失败`, error: error.message }); } }; // 删除代理配置 export const deleteProxy = async (req: Request, res: Response) => { - const db = getDb(); const { id } = req.params; try { - const sql = `DELETE FROM proxies WHERE id = ?`; - const result = await new Promise<{ changes: number }>((resolve, reject) => { - db.run(sql, [id], function (err) { // 使用 function 获取 this.changes - if (err) { - return reject(err); - } - resolve({ changes: this.changes }); - }); - }); + const proxyId = parseInt(id, 10); + if (isNaN(proxyId)) { + return res.status(400).json({ message: '无效的代理 ID' }); + } - if (result.changes > 0) { + const deleted = await ProxyService.deleteProxy(proxyId); + + if (deleted) { res.status(200).json({ message: `代理 ${id} 删除成功` }); } else { - // 如果 changes 为 0,说明没有找到对应 ID 的代理 res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行删除` }); } } catch (error: any) { + console.error(`Controller: 删除代理 ${id} 失败:`, error); res.status(500).json({ message: `删除代理 ${id} 失败`, error: error.message }); } }; diff --git a/packages/backend/src/repositories/connection.repository.ts b/packages/backend/src/repositories/connection.repository.ts new file mode 100644 index 0000000..7752155 --- /dev/null +++ b/packages/backend/src/repositories/connection.repository.ts @@ -0,0 +1,310 @@ +import { Database, Statement } from 'sqlite3'; +import { getDb } from '../database'; + +const db = getDb(); + +// 定义 Connection 类型 (可以从 controller 或 types 文件导入,暂时在此定义) +// 注意:这里不包含加密字段,因为 Repository 不应处理解密 +interface ConnectionBase { + id: number; + name: string; + host: string; + port: number; + username: string; + auth_method: 'password' | 'key'; + proxy_id: number | null; + created_at: number; + updated_at: number; + last_connected_at: number | null; +} + +interface ConnectionWithTags extends ConnectionBase { + tag_ids: number[]; +} + +// 包含加密字段的完整类型,用于插入/更新 +export interface FullConnectionData extends ConnectionBase { // <-- Added export + encrypted_password?: string | null; + encrypted_private_key?: string | null; + encrypted_passphrase?: string | null; +} + + +/** + * 获取所有连接及其标签 + */ +export const findAllConnectionsWithTags = async (): Promise => { + return new Promise((resolve, reject) => { + db.all( + `SELECT + c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id, + c.created_at, c.updated_at, c.last_connected_at, + GROUP_CONCAT(ct.tag_id) as tag_ids_str + FROM connections c + LEFT JOIN connection_tags ct ON c.id = ct.connection_id + GROUP BY c.id + ORDER BY c.name ASC`, + (err, rows: any[]) => { + if (err) { + console.error('Repository: 查询连接列表时出错:', err.message); + return reject(new Error('获取连接列表失败')); + } + const processedRows = rows.map(row => ({ + ...row, + tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number) : [] + })); + resolve(processedRows); + } + ); + }); +}; + +/** + * 根据 ID 获取单个连接及其标签 + */ +export const findConnectionByIdWithTags = async (id: number): Promise => { + return new Promise((resolve, reject) => { + db.get( + `SELECT + c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id, + c.created_at, c.updated_at, c.last_connected_at, + GROUP_CONCAT(ct.tag_id) as tag_ids_str + FROM connections c + LEFT JOIN connection_tags ct ON c.id = ct.connection_id + WHERE c.id = ? + GROUP BY c.id`, + [id], + (err, row: any) => { + if (err) { + console.error(`Repository: 查询连接 ${id} 时出错:`, err.message); + return reject(new Error('获取连接信息失败')); + } + if (row) { + row.tag_ids = row.tag_ids_str ? row.tag_ids_str.split(',').map(Number) : []; + delete row.tag_ids_str; + resolve(row); + } else { + resolve(null); + } + } + ); + }); +}; + +/** + * 根据 ID 获取单个连接的完整信息 (包括加密字段) + * 用于更新或测试连接等需要完整信息的场景 + */ +export const findFullConnectionById = async (id: number): Promise => { + return new Promise((resolve, reject) => { + // 查询连接信息,并 LEFT JOIN 代理信息 (因为测试连接需要) + // 注意:这里返回的结构比较复杂,服务层需要处理 + db.get( + `SELECT + c.*, -- 选择 connections 表所有列 + p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type, + p.host as proxy_host, p.port as proxy_port, p.username as proxy_username, + p.auth_method as proxy_auth_method, -- 包含代理的 auth_method + p.encrypted_password as proxy_encrypted_password, + p.encrypted_private_key as proxy_encrypted_private_key, -- 包含代理的 key + p.encrypted_passphrase as proxy_encrypted_passphrase -- 包含代理的 passphrase + FROM connections c + LEFT JOIN proxies p ON c.proxy_id = p.id + WHERE c.id = ?`, + [id], + (err, row: any) => { + if (err) { + console.error(`Repository: 查询连接 ${id} 详细信息时出错:`, err.message); + return reject(new Error('获取连接详细信息失败')); + } + resolve(row || null); + } + ); + }); + }; + + +/** + * 创建新连接 + */ +export const createConnection = async (data: Omit): Promise => { + return new Promise((resolve, reject) => { + const now = Math.floor(Date.now() / 1000); + const stmt = db.prepare( + `INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + stmt.run( + data.name, 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, + now, now, + function (this: Statement, err: Error | null) { + stmt.finalize(); // 确保 finalize 被调用 + if (err) { + console.error('Repository: 插入连接时出错:', err.message); + return reject(new Error('创建连接记录失败')); + } + resolve((this as any).lastID); + } + ); + }); +}; + +/** + * 更新连接信息 + */ +export const updateConnection = async (id: number, data: Partial>): Promise => { + const fieldsToUpdate: { [key: string]: any } = { ...data }; + const params: any[] = []; + + // 移除 id, created_at, last_connected_at (不应通过此方法更新) + delete fieldsToUpdate.id; + delete fieldsToUpdate.created_at; + delete fieldsToUpdate.last_connected_at; + + // 设置 updated_at + 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)); // 处理 undefined 为 null + + if (!setClauses) { + return false; // 没有要更新的字段 + } + + params.push(id); // 添加 WHERE id = ? 的参数 + + return new Promise((resolve, reject) => { + const stmt = db.prepare( + `UPDATE connections SET ${setClauses} WHERE id = ?` + ); + stmt.run(...params, function (this: Statement, err: Error | null) { + stmt.finalize(); + if (err) { + console.error(`Repository: 更新连接 ${id} 时出错:`, err.message); + return reject(new Error('更新连接记录失败')); + } + resolve((this as any).changes > 0); + }); + }); +}; + + +/** + * 删除连接 + */ +export const deleteConnection = async (id: number): Promise => { + return new Promise((resolve, reject) => { + const stmt = db.prepare( + `DELETE FROM connections WHERE id = ?` + ); + stmt.run(id, function (this: Statement, err: Error | null) { + stmt.finalize(); + if (err) { + console.error(`Repository: 删除连接 ${id} 时出错:`, err.message); + return reject(new Error('删除连接记录失败')); + } + resolve((this as any).changes > 0); + }); + }); +}; + +/** + * 更新连接的标签关联 + * @param connectionId 连接 ID + * @param tagIds 新的标签 ID 数组 (空数组表示清除所有标签) + */ +export const updateConnectionTags = async (connectionId: number, tagIds: number[]): Promise => { + const deleteStmt = db.prepare(`DELETE FROM connection_tags WHERE connection_id = ?`); + const insertStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`); + + return new Promise((resolve, reject) => { + db.serialize(() => { + db.run('BEGIN TRANSACTION'); + try { + // 1. 删除旧关联 + deleteStmt.run(connectionId, (err: Error | null) => { + if (err) throw err; + }); + deleteStmt.finalize(); + + // 2. 插入新关联 (如果 tagIds 不为空) + if (tagIds.length > 0) { + tagIds.forEach((tagId: any) => { + if (typeof tagId === 'number' && tagId > 0) { + insertStmt.run(connectionId, tagId, (err: Error | null) => { + if (err) throw err; + }); + } else { + console.warn(`Repository: 更新连接 ${connectionId} 标签时,提供的 tag_id 无效: ${tagId}`); + } + }); + } + insertStmt.finalize(); + db.run('COMMIT', (commitErr: Error | null) => { + if (commitErr) throw commitErr; + resolve(); // 事务成功 + }); + } catch (tagError: any) { + console.error(`Repository: 更新连接 ${connectionId} 的标签关联时出错:`, tagError); + db.run('ROLLBACK'); + reject(new Error('处理标签关联失败')); + } + }); + }); +}; + +/** + * 批量插入连接(用于导入) + * 注意:此函数应在事务中调用 + */ +export const bulkInsertConnections = async (connections: Omit[]): Promise<{ connectionId: number, originalData: any }[]> => { + const insertConnStmt = db.prepare(`INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + const insertTagStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`); + const results: { connectionId: number, originalData: any }[] = []; + const now = Math.floor(Date.now() / 1000); + + try { + for (const connData of connections) { + const connResult = await new Promise<{ lastID: number }>((resolve, reject) => { + insertConnStmt.run( + connData.name, connData.host, connData.port, connData.username, connData.auth_method, + connData.encrypted_password || null, + connData.encrypted_private_key || null, + connData.encrypted_passphrase || null, + connData.proxy_id || null, + now, now, + function (this: Statement, err: Error | null) { + if (err) return reject(new Error(`插入连接 "${connData.name}" 时出错: ${err.message}`)); + resolve({ lastID: (this as any).lastID }); + } + ); + }); + const newConnectionId = connResult.lastID; + results.push({ connectionId: newConnectionId, originalData: connData }); // Store ID and original data for tag association + + // 处理标签关联 (在同一个事务中) + if (Array.isArray((connData as any).tag_ids) && (connData as any).tag_ids.length > 0) { + for (const tagId of (connData as any).tag_ids) { + if (typeof tagId === 'number' && tagId > 0) { + await new Promise((resolve, reject) => { + insertTagStmt.run(newConnectionId, tagId, (err: Error | null) => { + if (err) { + // 警告但不中断整个导入 + console.warn(`Repository: 导入连接 ${connData.name}: 关联标签 ID ${tagId} 失败: ${err.message}`); + } + resolve(); + }); + }); + } + } + } + } + return results; + } finally { + // Finalize statements after the loop + insertConnStmt.finalize(); + insertTagStmt.finalize(); + } +}; diff --git a/packages/backend/src/repositories/proxy.repository.ts b/packages/backend/src/repositories/proxy.repository.ts new file mode 100644 index 0000000..f17ae7c --- /dev/null +++ b/packages/backend/src/repositories/proxy.repository.ts @@ -0,0 +1,153 @@ +import { Database, Statement } from 'sqlite3'; +import { getDb } from '../database'; + +const db = getDb(); + +// 定义 Proxy 类型 (可以共享到 types 文件) +export interface ProxyData { + id: number; + name: string; + type: 'SOCKS5' | 'HTTP'; + host: string; + port: number; + username?: string | null; + auth_method: 'none' | 'password' | 'key'; + encrypted_password?: string | null; + encrypted_private_key?: string | null; + encrypted_passphrase?: string | null; + created_at: number; + updated_at: number; +} + +/** + * 根据名称、类型、主机和端口查找代理 + */ +export const findProxyByNameTypeHostPort = async (name: string, type: string, host: string, port: number): Promise<{ id: number } | undefined> => { + return new Promise((resolve, reject) => { + db.get( + `SELECT id FROM proxies WHERE name = ? AND type = ? AND host = ? AND port = ?`, + [name, type, host, port], + (err: Error | null, row: { id: number } | undefined) => { + if (err) { + console.error(`Repository: 查找代理时出错 (name=${name}, type=${type}, host=${host}, port=${port}):`, err.message); + return reject(new Error(`查找代理时出错: ${err.message}`)); + } + resolve(row); + } + ); + }); +}; + +/** + * 创建新代理 + */ +export const createProxy = async (data: Omit): Promise => { + return new Promise((resolve, reject) => { + const now = Math.floor(Date.now() / 1000); + const stmt = db.prepare( + `INSERT INTO proxies (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + stmt.run( + data.name, data.type, data.host, data.port, + data.username || null, + data.auth_method || 'none', + data.encrypted_password || null, + data.encrypted_private_key || null, + data.encrypted_passphrase || null, + now, now, + function (this: Statement, err: Error | null) { + stmt.finalize(); + if (err) { + console.error('Repository: 创建代理时出错:', err.message); + return reject(new Error(`创建代理时出错: ${err.message}`)); + } + resolve((this as any).lastID); + } + ); + }); +}; + +/** + * 获取所有代理 + */ +export const findAllProxies = async (): Promise => { + return new Promise((resolve, reject) => { + db.all(`SELECT * FROM proxies ORDER BY name ASC`, (err, rows: ProxyData[]) => { + if (err) { + console.error('Repository: 查询代理列表时出错:', err.message); + return reject(new Error('获取代理列表失败')); + } + resolve(rows); + }); + }); +}; + +/** + * 根据 ID 获取单个代理 + */ +export const findProxyById = async (id: number): Promise => { + return new Promise((resolve, reject) => { + db.get(`SELECT * FROM proxies WHERE id = ?`, [id], (err, row: ProxyData) => { + if (err) { + console.error(`Repository: 查询代理 ${id} 时出错:`, err.message); + return reject(new Error('获取代理信息失败')); + } + resolve(row || null); + }); + }); + }; + + +/** + * 更新代理信息 + */ +export const updateProxy = async (id: number, data: Partial>): Promise => { + const fieldsToUpdate: { [key: string]: any } = { ...data }; + const params: any[] = []; + + delete fieldsToUpdate.id; + delete fieldsToUpdate.created_at; + + 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)); + + if (!setClauses) { + return false; + } + + params.push(id); + + return new Promise((resolve, reject) => { + const stmt = db.prepare(`UPDATE proxies SET ${setClauses} WHERE id = ?`); + stmt.run(...params, function (this: Statement, err: Error | null) { + stmt.finalize(); + if (err) { + console.error(`Repository: 更新代理 ${id} 时出错:`, err.message); + return reject(new Error('更新代理记录失败')); + } + resolve((this as any).changes > 0); + }); + }); +}; + +/** + * 删除代理 + */ +export const deleteProxy = async (id: number): Promise => { + return new Promise((resolve, reject) => { + // 注意:connections 表中的 proxy_id 外键设置了 ON DELETE SET NULL, + // 所以删除代理时,关联的连接会自动将 proxy_id 设为 NULL。 + const stmt = db.prepare(`DELETE FROM proxies WHERE id = ?`); + stmt.run(id, function (this: Statement, err: Error | null) { + stmt.finalize(); + if (err) { + console.error(`Repository: 删除代理 ${id} 时出错:`, err.message); + return reject(new Error('删除代理记录失败')); + } + resolve((this as any).changes > 0); + }); + }); +}; diff --git a/packages/backend/src/repositories/tag.repository.ts b/packages/backend/src/repositories/tag.repository.ts new file mode 100644 index 0000000..bcd2bca --- /dev/null +++ b/packages/backend/src/repositories/tag.repository.ts @@ -0,0 +1,104 @@ +import { Database, Statement } from 'sqlite3'; +import { getDb } from '../database'; + +const db = getDb(); + +// 定义 Tag 类型 (可以共享到 types 文件) +export interface TagData { + id: number; + name: string; + created_at: number; + updated_at: number; // Assuming tags also have updated_at based on migrations +} + +/** + * 获取所有标签 + */ +export const findAllTags = async (): Promise => { + return new Promise((resolve, reject) => { + db.all(`SELECT * FROM tags ORDER BY name ASC`, [], (err, rows: TagData[]) => { + if (err) { + console.error('Repository: 查询标签列表时出错:', err.message); + return reject(new Error('获取标签列表失败')); + } + resolve(rows); + }); + }); +}; + +/** + * 根据 ID 获取单个标签 + */ +export const findTagById = async (id: number): Promise => { + return new Promise((resolve, reject) => { + db.get(`SELECT * FROM tags WHERE id = ?`, [id], (err, row: TagData) => { + if (err) { + console.error(`Repository: 查询标签 ${id} 时出错:`, err.message); + return reject(new Error('获取标签信息失败')); + } + resolve(row || null); + }); + }); + }; + + +/** + * 创建新标签 + */ +export const createTag = async (name: string): Promise => { + return new Promise((resolve, reject) => { + const now = Math.floor(Date.now() / 1000); + const stmt = db.prepare( + `INSERT INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)` + ); + stmt.run(name, now, now, function (this: Statement, err: Error | null) { + stmt.finalize(); + if (err) { + // Handle unique constraint error specifically if needed + console.error('Repository: 创建标签时出错:', err.message); + return reject(new Error(`创建标签失败: ${err.message}`)); + } + resolve((this as any).lastID); + }); + }); +}; + +/** + * 更新标签名称 + */ +export const updateTag = async (id: number, name: string): Promise => { + return new Promise((resolve, reject) => { + const now = Math.floor(Date.now() / 1000); + const stmt = db.prepare( + `UPDATE tags SET name = ?, updated_at = ? WHERE id = ?` + ); + stmt.run(name, now, id, function (this: Statement, err: Error | null) { + stmt.finalize(); + if (err) { + // Handle unique constraint error specifically if needed + console.error(`Repository: 更新标签 ${id} 时出错:`, err.message); + return reject(new Error(`更新标签失败: ${err.message}`)); + } + resolve((this as any).changes > 0); + }); + }); +}; + +/** + * 删除标签 + */ +export const deleteTag = async (id: number): Promise => { + return new Promise((resolve, reject) => { + // Note: connection_tags junction table has ON DELETE CASCADE for tag_id, + // so related entries there will be deleted automatically. + const stmt = db.prepare(`DELETE FROM tags WHERE id = ?`); + stmt.run(id, function (this: Statement, err: Error | null) { + stmt.finalize(); + if (err) { + console.error(`Repository: 删除标签 ${id} 时出错:`, err.message); + return reject(new Error('删除标签失败')); + } + resolve((this as any).changes > 0); + }); + }); +}; diff --git a/packages/backend/src/services/connection.service.ts b/packages/backend/src/services/connection.service.ts new file mode 100644 index 0000000..cd2b661 --- /dev/null +++ b/packages/backend/src/services/connection.service.ts @@ -0,0 +1,214 @@ +import * as ConnectionRepository from '../repositories/connection.repository'; +import { encrypt, decrypt } from '../utils/crypto'; + +// Re-export or define types needed by the controller/service +// Ideally, these would be in a shared types file, e.g., packages/backend/src/types/connection.types.ts +// For now, let's reuse the interfaces from the repository (adjust as needed) +export interface ConnectionBase { + id: number; + name: string; + host: string; + port: number; + username: string; + auth_method: 'password' | 'key'; + proxy_id: number | null; + created_at: number; + updated_at: number; + last_connected_at: number | null; +} + +export interface ConnectionWithTags extends ConnectionBase { + tag_ids: number[]; +} + +// Input type for creating a connection (from controller) +export interface CreateConnectionInput { + name: string; + host: string; + port?: number; // Optional, defaults in service/repo + username: string; + auth_method: 'password' | 'key'; + password?: string; // Optional depending on auth_method + private_key?: string; // Optional depending on auth_method + passphrase?: string; // Optional for key auth + proxy_id?: number | null; + tag_ids?: number[]; +} + +// Input type for updating a connection (from controller) +// All fields are optional except potentially auth_method related ones +export interface UpdateConnectionInput { + name?: string; + host?: string; + port?: number; + username?: string; + auth_method?: 'password' | 'key'; + password?: string; + private_key?: string; + passphrase?: string; // Use undefined to signal no change, null/empty string to clear + proxy_id?: number | null; + tag_ids?: number[]; +} + + +/** + * 获取所有连接(包含标签) + */ +export const getAllConnections = async (): Promise => { + return ConnectionRepository.findAllConnectionsWithTags(); +}; + +/** + * 根据 ID 获取单个连接(包含标签) + */ +export const getConnectionById = async (id: number): Promise => { + return ConnectionRepository.findConnectionByIdWithTags(id); +}; + +/** + * 创建新连接 + */ +export const createConnection = async (input: CreateConnectionInput): Promise => { + // 1. Validate input (basic validation, more complex validation can be added) + if (!input.name || !input.host || !input.username || !input.auth_method) { + throw new Error('缺少必要的连接信息 (name, host, username, auth_method)。'); + } + if (input.auth_method === 'password' && !input.password) { + throw new Error('密码认证方式需要提供 password。'); + } + if (input.auth_method === 'key' && !input.private_key) { + throw new Error('密钥认证方式需要提供 private_key。'); + } + // Add more validation as needed (port range, proxy existence etc.) + + // 2. Encrypt credentials + let encryptedPassword = null; + let encryptedPrivateKey = null; + let encryptedPassphrase = null; + + if (input.auth_method === 'password') { + encryptedPassword = encrypt(input.password!); + } else if (input.auth_method === 'key') { + encryptedPrivateKey = encrypt(input.private_key!); + if (input.passphrase) { + encryptedPassphrase = encrypt(input.passphrase); + } + } + + // 3. Prepare data for repository + const connectionData = { + name: input.name, + host: input.host, + port: input.port ?? 22, // Default port + username: input.username, + auth_method: input.auth_method, + encrypted_password: encryptedPassword, + encrypted_private_key: encryptedPrivateKey, + encrypted_passphrase: encryptedPassphrase, + proxy_id: input.proxy_id ?? null, + }; + + // 4. Create connection record in repository + const newConnectionId = await ConnectionRepository.createConnection(connectionData); + + // 5. Handle tags + const tagIds = input.tag_ids?.filter(id => typeof id === 'number' && id > 0) ?? []; + if (tagIds.length > 0) { + await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds); + } + + // 6. Fetch and return the newly created connection with tags + const newConnection = await getConnectionById(newConnectionId); + if (!newConnection) { + // This should ideally not happen if creation was successful + throw new Error('创建连接后无法检索到该连接。'); + } + return newConnection; +}; + +/** + * 更新连接信息 + */ +export const updateConnection = async (id: number, input: UpdateConnectionInput): Promise => { + // 1. Fetch current connection data (including encrypted fields) to compare + const currentFullConnection = await ConnectionRepository.findFullConnectionById(id); + if (!currentFullConnection) { + return null; // Connection not found + } + + // 2. Prepare data for update + const dataToUpdate: Partial = {}; + let needsCredentialUpdate = false; + let newAuthMethod = input.auth_method || currentFullConnection.auth_method; + + // Update non-credential fields + if (input.name !== undefined) dataToUpdate.name = input.name; + 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.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id; // Allows setting to null + + // Handle auth method change or credential update + if (input.auth_method && input.auth_method !== currentFullConnection.auth_method) { + // Auth method changed + dataToUpdate.auth_method = input.auth_method; + needsCredentialUpdate = true; + if (input.auth_method === 'password') { + if (!input.password) throw new Error('切换到密码认证时需要提供 password。'); + dataToUpdate.encrypted_password = encrypt(input.password); + dataToUpdate.encrypted_private_key = null; + dataToUpdate.encrypted_passphrase = null; + } else { // key + if (!input.private_key) throw new Error('切换到密钥认证时需要提供 private_key。'); + dataToUpdate.encrypted_private_key = encrypt(input.private_key); + dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; + dataToUpdate.encrypted_password = null; + } + } else { + // Auth method did not change, check if credentials for the current method were provided + if (newAuthMethod === 'password' && input.password !== undefined) { + dataToUpdate.encrypted_password = encrypt(input.password); + needsCredentialUpdate = true; + } else if (newAuthMethod === 'key') { + if (input.private_key !== undefined) { + dataToUpdate.encrypted_private_key = encrypt(input.private_key); + // Passphrase must be updated (or cleared) if private key is updated + dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; + needsCredentialUpdate = true; + } else if (input.passphrase !== undefined) { // Only passphrase provided + dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; + needsCredentialUpdate = true; + } + } + } + + // 3. Update connection record if there are changes + const hasNonTagChanges = Object.keys(dataToUpdate).length > 0; + if (hasNonTagChanges) { + const updated = await ConnectionRepository.updateConnection(id, dataToUpdate); + if (!updated) { + // Should not happen if findFullConnectionById succeeded, but good practice + throw new Error('更新连接记录失败。'); + } + } + + // 4. Handle tags update if tag_ids were provided + if (input.tag_ids !== undefined) { + const validTagIds = input.tag_ids.filter(tagId => typeof tagId === 'number' && tagId > 0); + await ConnectionRepository.updateConnectionTags(id, validTagIds); + } + + // 5. Fetch and return the updated connection + return getConnectionById(id); +}; + + +/** + * 删除连接 + */ +export const deleteConnection = async (id: number): Promise => { + return ConnectionRepository.deleteConnection(id); +}; + +// Note: testConnection, importConnections, exportConnections logic +// will be moved to SshService and ImportExportService respectively. diff --git a/packages/backend/src/services/import-export.service.ts b/packages/backend/src/services/import-export.service.ts new file mode 100644 index 0000000..1ce3039 --- /dev/null +++ b/packages/backend/src/services/import-export.service.ts @@ -0,0 +1,291 @@ +import * as ConnectionRepository from '../repositories/connection.repository'; +import * as ProxyRepository from '../repositories/proxy.repository'; +import { getDb } from '../database'; // Need db instance for transaction + +const db = getDb(); // Get db instance for transaction management + +// Define structure for imported connection data (can be shared in types) +interface ImportedConnectionData { + name: string; + host: string; + port: number; + username: string; + auth_method: 'password' | 'key'; + encrypted_password?: string | null; + encrypted_private_key?: string | null; + encrypted_passphrase?: string | null; + tag_ids?: number[]; + proxy?: { + name: string; + type: 'SOCKS5' | 'HTTP'; + host: string; + port: number; + username?: string | null; + auth_method?: 'none' | 'password' | 'key'; + encrypted_password?: string | null; + encrypted_private_key?: string | null; + encrypted_passphrase?: string | null; + } | null; +} + +// Define structure for exported connection data (can be shared in types) +interface ExportedConnectionData extends Omit { + // Exclude fields not needed for export like id, created_at etc. +} + +// Define structure for import results +export interface ImportResult { + successCount: number; + failureCount: number; + errors: { connectionName?: string; message: string }[]; +} + +/** + * 导出所有连接配置 + */ +export const exportConnections = async (): Promise => { + // 1. Fetch all connections with tags (basic info) + // We need full connection info including encrypted fields and proxy details for export + // Let's adapt the repository or add a new method if needed. + // For now, let's assume findFullConnectionById can be adapted or a similar findAll method exists. + // Re-using the logic from controller for now, ideally repo handles joins. + + const connectionsWithProxies = await new Promise((resolve, reject) => { + db.all( + `SELECT + c.id, c.name, c.host, c.port, c.username, c.auth_method, + c.encrypted_password, c.encrypted_private_key, c.encrypted_passphrase, + c.proxy_id, + p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type, + p.host as proxy_host, p.port as proxy_port, p.username as proxy_username, + p.auth_method as proxy_auth_method, + p.encrypted_password as proxy_encrypted_password, + p.encrypted_private_key as proxy_encrypted_private_key, + p.encrypted_passphrase as proxy_encrypted_passphrase + FROM connections c + LEFT JOIN proxies p ON c.proxy_id = p.id + ORDER BY c.name ASC`, + (err, rows: any[]) => { + if (err) { + console.error('Service: 查询连接和代理信息以供导出时出错:', err.message); + return reject(new Error('导出连接失败:查询连接信息出错')); + } + resolve(rows); + } + ); + }); + + const connectionTags = await new Promise<{[connId: number]: number[]}>((resolve, reject) => { + db.all('SELECT connection_id, tag_id FROM connection_tags', (err, rows: {connection_id: number, tag_id: number}[]) => { + if (err) { + console.error('Service: 查询连接标签以供导出时出错:', err.message); + return reject(new Error('导出连接失败:查询标签信息出错')); + } + const tagsMap: {[connId: number]: number[]} = {}; + rows.forEach(row => { + if (!tagsMap[row.connection_id]) tagsMap[row.connection_id] = []; + tagsMap[row.connection_id].push(row.tag_id); + }); + resolve(tagsMap); + }); + }); + + // 2. Format data for export + const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => { + const connection: ExportedConnectionData = { + name: row.name, + host: row.host, + port: row.port, + username: row.username, + auth_method: row.auth_method, + encrypted_password: row.encrypted_password, + encrypted_private_key: row.encrypted_private_key, + encrypted_passphrase: row.encrypted_passphrase, + tag_ids: connectionTags[row.id] || [], + proxy: null // Initialize proxy as null + }; + + if (row.proxy_db_id) { + connection.proxy = { + name: row.proxy_name, + type: row.proxy_type, + host: row.proxy_host, + port: row.proxy_port, + username: row.proxy_username, + auth_method: row.proxy_auth_method, + encrypted_password: row.proxy_encrypted_password, + encrypted_private_key: row.proxy_encrypted_private_key, + encrypted_passphrase: row.proxy_encrypted_passphrase, + }; + } + return connection; + }); + + return formattedData; +}; + + +/** + * 导入连接配置 + * @param fileBuffer Buffer containing the JSON file content + */ +export const importConnections = async (fileBuffer: Buffer): Promise => { + let importedData: ImportedConnectionData[]; + try { + const fileContent = fileBuffer.toString('utf8'); + importedData = JSON.parse(fileContent); + if (!Array.isArray(importedData)) { + throw new Error('JSON 文件内容必须是一个数组。'); + } + } catch (error: any) { + console.error('Service: 解析导入文件失败:', error); + throw new Error(`解析 JSON 文件失败: ${error.message}`); // Re-throw for controller + } + + let successCount = 0; + let failureCount = 0; + const errors: { connectionName?: string; message: string }[] = []; + const connectionsToInsert: Omit[] = []; + + // Use a transaction for atomicity + return new Promise((resolveOuter, rejectOuter) => { + db.serialize(() => { + db.run('BEGIN TRANSACTION', async (beginErr: Error | null) => { + if (beginErr) { + console.error('Service: 开始导入事务失败:', beginErr); + return rejectOuter(new Error(`开始事务失败: ${beginErr.message}`)); + } + + try { + // Process each connection data from the imported file + for (const connData of importedData) { + try { + // 1. Validate connection data (basic) + if (!connData.name || !connData.host || !connData.port || !connData.username || !connData.auth_method) { + throw new Error('缺少必要的连接字段 (name, host, port, username, auth_method)。'); + } + if (connData.auth_method === 'password' && !connData.encrypted_password) { + throw new Error('密码认证缺少 encrypted_password。'); + } + if (connData.auth_method === 'key' && !connData.encrypted_private_key) { + throw new Error('密钥认证缺少 encrypted_private_key。'); + } + // Add more validation as needed + + let proxyIdToUse: number | null = null; + + // 2. Handle proxy (find or create) + if (connData.proxy) { + const proxyData = connData.proxy; + // Validate proxy data + if (!proxyData.name || !proxyData.type || !proxyData.host || !proxyData.port) { + throw new Error('代理信息不完整 (缺少 name, type, host, port)。'); + } + // Add more proxy validation if needed + + // Try to find existing proxy + const existingProxy = await ProxyRepository.findProxyByNameTypeHostPort(proxyData.name, proxyData.type, proxyData.host, proxyData.port); + + if (existingProxy) { + proxyIdToUse = existingProxy.id; + } else { + // Proxy doesn't exist, create it + const newProxyData = { + name: proxyData.name, + type: proxyData.type, + host: proxyData.host, + port: proxyData.port, + username: proxyData.username || null, + auth_method: proxyData.auth_method || 'none', + encrypted_password: proxyData.encrypted_password || null, + encrypted_private_key: proxyData.encrypted_private_key || null, + encrypted_passphrase: proxyData.encrypted_passphrase || null, + }; + proxyIdToUse = await ProxyRepository.createProxy(newProxyData); + console.log(`Service: 导入连接 ${connData.name}: 新代理 ${proxyData.name} 创建成功 (ID: ${proxyIdToUse})`); + } + } + + // 3. Prepare connection data for bulk insert + connectionsToInsert.push({ + name: connData.name, + host: connData.host, + port: connData.port, + username: connData.username, + auth_method: connData.auth_method, + encrypted_password: connData.encrypted_password || null, + encrypted_private_key: connData.encrypted_private_key || null, + encrypted_passphrase: connData.encrypted_passphrase || null, + proxy_id: proxyIdToUse, + // tag_ids will be handled separately after insertion + }); + + } catch (connError: any) { + // Error processing this specific connection + failureCount++; + errors.push({ connectionName: connData.name || '未知连接', message: connError.message }); + console.warn(`Service: 处理导入连接 "${connData.name || '未知'}" 时出错: ${connError.message}`); + } + } // End for loop + + // 4. Bulk insert connections + let insertedResults: { connectionId: number, originalData: any }[] = []; + if (connectionsToInsert.length > 0) { + insertedResults = await ConnectionRepository.bulkInsertConnections(connectionsToInsert); + successCount = insertedResults.length; + } + + // 5. Associate tags for successfully inserted connections + for (const result of insertedResults) { + const originalTagIds = result.originalData?.tag_ids; + if (Array.isArray(originalTagIds) && originalTagIds.length > 0) { + const validTagIds = originalTagIds.filter((id: any) => typeof id === 'number' && id > 0); + if (validTagIds.length > 0) { + try { + await ConnectionRepository.updateConnectionTags(result.connectionId, validTagIds); + } catch (tagError: any) { + // Log warning but don't fail the entire import for tag association error + console.warn(`Service: 导入连接 ${result.originalData.name}: 关联标签失败 (ID: ${result.connectionId}): ${tagError.message}`); + // Optionally, add this to the 'errors' array reported back + errors.push({ connectionName: result.originalData.name, message: `关联标签失败: ${tagError.message}` }); + // Decrement successCount or increment failureCount if tag failure should count as overall failure + // failureCount++; // Example: Count tag failures + } + } + } + } + + + // 6. Commit or Rollback + if (failureCount > 0 && successCount === 0) { // Only rollback if ALL fail, or adjust logic as needed + console.warn(`Service: 导入连接存在 ${failureCount} 个错误,且无成功记录,正在回滚事务...`); + db.run('ROLLBACK', (rollbackErr: Error | null) => { + if (rollbackErr) console.error("Service: 回滚事务失败:", rollbackErr); + // Reject outer promise with collected errors + rejectOuter(new Error(`导入失败,存在 ${failureCount} 个错误。`)); + }); + } else { + // Commit even if some failed, report partial success + db.run('COMMIT', (commitErr: Error | null) => { + if (commitErr) { + console.error('Service: 提交导入事务时出错:', commitErr); + rejectOuter(new Error(`提交导入事务失败: ${commitErr.message}`)); + } else { + console.log(`Service: 导入事务提交。成功: ${successCount}, 失败: ${failureCount}`); + resolveOuter({ successCount, failureCount, errors }); // Resolve outer promise + } + }); + } + + } catch (innerError: any) { + // Catch errors during the process (e.g., bulk insert failure) + console.error('Service: 导入事务内部出错:', innerError); + db.run('ROLLBACK', (rollbackErr: Error | null) => { + if (rollbackErr) console.error("Service: 回滚事务失败:", rollbackErr); + rejectOuter(innerError); // Reject outer promise + }); + } + }); // End BEGIN TRANSACTION + }); // End db.serialize + }); // End new Promise +}; diff --git a/packages/backend/src/services/proxy.service.ts b/packages/backend/src/services/proxy.service.ts new file mode 100644 index 0000000..a959ae9 --- /dev/null +++ b/packages/backend/src/services/proxy.service.ts @@ -0,0 +1,174 @@ +import * as ProxyRepository from '../repositories/proxy.repository'; +import { encrypt, decrypt } from '../utils/crypto'; // Assuming crypto utils are needed + +// Re-export or define types (ideally from a shared types file) +export interface ProxyData extends ProxyRepository.ProxyData {} + +// Input type for creating a proxy +export interface CreateProxyInput { + name: string; + type: 'SOCKS5' | 'HTTP'; + host: string; + port: number; + username?: string | null; + auth_method?: 'none' | 'password' | 'key'; // Optional, defaults to 'none' + password?: string | null; // Plain text password + private_key?: string | null; // Plain text private key + passphrase?: string | null; // Plain text passphrase +} + +// Input type for updating a proxy +export interface UpdateProxyInput { + name?: string; + type?: 'SOCKS5' | 'HTTP'; + host?: string; + port?: number; + username?: string | null; + auth_method?: 'none' | 'password' | 'key'; + password?: string | null; // Use undefined for no change, null/empty to clear + private_key?: string | null; // Use undefined for no change, null/empty to clear + passphrase?: string | null; // Use undefined for no change, null/empty to clear +} + + +/** + * 获取所有代理 + */ +export const getAllProxies = async (): Promise => { + // Repository returns data with encrypted fields, which is fine for listing generally + // If decryption is needed for display, it should happen closer to the presentation layer or selectively + return ProxyRepository.findAllProxies(); +}; + +/** + * 根据 ID 获取单个代理 + */ +export const getProxyById = async (id: number): Promise => { + // Repository returns data with encrypted fields + return ProxyRepository.findProxyById(id); +}; + +/** + * 创建新代理 + */ +export const createProxy = async (input: CreateProxyInput): Promise => { + // 1. Validate input + if (!input.name || !input.type || !input.host || !input.port) { + throw new Error('缺少必要的代理信息 (name, type, host, port)。'); + } + if (input.auth_method === 'password' && !input.password) { + throw new Error('代理密码认证方式需要提供 password。'); + } + if (input.auth_method === 'key' && !input.private_key) { + throw new Error('代理密钥认证方式需要提供 private_key。'); + } + // Add more validation (port range, type check etc.) + + // 2. Encrypt credentials if provided + const encryptedPassword = input.password ? encrypt(input.password) : null; + const encryptedPrivateKey = input.private_key ? encrypt(input.private_key) : null; + const encryptedPassphrase = input.passphrase ? encrypt(input.passphrase) : null; + + // 3. Prepare data for repository + const proxyData: Omit = { + name: input.name, + type: input.type, + host: input.host, + port: input.port, + username: input.username || null, + auth_method: input.auth_method || 'none', + encrypted_password: encryptedPassword, + encrypted_private_key: encryptedPrivateKey, + encrypted_passphrase: encryptedPassphrase, + }; + + // 4. Create proxy record + const newProxyId = await ProxyRepository.createProxy(proxyData); + + // 5. Fetch and return the newly created proxy + const newProxy = await getProxyById(newProxyId); + if (!newProxy) { + throw new Error('创建代理后无法检索到该代理。'); + } + return newProxy; +}; + +/** + * 更新代理信息 + */ +export const updateProxy = async (id: number, input: UpdateProxyInput): Promise => { + // 1. Fetch current proxy data to compare if needed (e.g., for auth method change logic) + const currentProxy = await ProxyRepository.findProxyById(id); + if (!currentProxy) { + return null; // Proxy not found + } + + // 2. Prepare data for update + const dataToUpdate: Partial> = {}; + let needsCredentialUpdate = false; + const newAuthMethod = input.auth_method || currentProxy.auth_method; + + // Update standard fields + if (input.name !== undefined) dataToUpdate.name = input.name; + if (input.type !== undefined) dataToUpdate.type = input.type; + if (input.host !== undefined) dataToUpdate.host = input.host; + if (input.port !== undefined) dataToUpdate.port = input.port; + if (input.username !== undefined) dataToUpdate.username = input.username; // Allows clearing + + // Handle auth method change or credential update + if (input.auth_method && input.auth_method !== currentProxy.auth_method) { + dataToUpdate.auth_method = input.auth_method; + needsCredentialUpdate = true; + // Encrypt new credentials based on the *new* auth_method + if (input.auth_method === 'password') { + if (input.password === undefined) throw new Error('切换到密码认证时需要提供 password。'); + dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null; + dataToUpdate.encrypted_private_key = null; // Clear old key info + dataToUpdate.encrypted_passphrase = null; + } else if (input.auth_method === 'key') { + if (input.private_key === undefined) throw new Error('切换到密钥认证时需要提供 private_key。'); + dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null; + dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; + dataToUpdate.encrypted_password = null; // Clear old password info + } else { // 'none' + dataToUpdate.encrypted_password = null; + dataToUpdate.encrypted_private_key = null; + dataToUpdate.encrypted_passphrase = null; + } + } else { + // Auth method unchanged, update credentials if provided for the current method + if (newAuthMethod === 'password' && input.password !== undefined) { + dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null; + needsCredentialUpdate = true; + } else if (newAuthMethod === 'key') { + if (input.private_key !== undefined) { + dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null; + dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; // Update passphrase together + needsCredentialUpdate = true; + } else if (input.passphrase !== undefined) { // Only passphrase updated + dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; + needsCredentialUpdate = true; + } + } + } + + // 3. Update proxy record if there are changes + const hasChanges = Object.keys(dataToUpdate).length > 0; + if (hasChanges) { + const updated = await ProxyRepository.updateProxy(id, dataToUpdate); + if (!updated) { + throw new Error('更新代理记录失败。'); + } + } + + // 4. Fetch and return the updated proxy + return getProxyById(id); +}; + +/** + * 删除代理 + */ +export const deleteProxy = async (id: number): Promise => { + // Repository handles setting foreign keys to NULL in connections table + return ProxyRepository.deleteProxy(id); +}; diff --git a/packages/backend/src/services/sftp.service.ts b/packages/backend/src/services/sftp.service.ts new file mode 100644 index 0000000..fcc429f --- /dev/null +++ b/packages/backend/src/services/sftp.service.ts @@ -0,0 +1,186 @@ +import { Client, SFTPWrapper, Stats, Dirent } from 'ssh2'; // 导入 Stats 和 Dirent 类型 +import { WebSocket } from 'ws'; +// import { logger } from '../utils/logger'; // 不再使用自定义 logger,改用 console + +// 定义客户端状态接口 +interface ClientState { + ws: WebSocket; + sshClient: Client; + sftp?: SFTPWrapper; + // 如果需要,可以添加其他相关的状态属性 +} + +export class SftpService { + private clientStates: Map; // 存储 connectionId 到 ClientState 的映射 + + constructor(clientStates: Map) { + this.clientStates = clientStates; + } + + /** + * 初始化 SFTP 会话 + * @param connectionId 连接 ID + */ + async initializeSftpSession(connectionId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sshClient || state.sftp) { + console.warn(`[SFTP] 无法为 ${connectionId} 初始化 SFTP:状态无效或 SFTP 已初始化。`); + return; + } + + return new Promise((resolve, reject) => { + state.sshClient.sftp((err, sftp) => { + if (err) { + console.error(`[SFTP] 为 ${connectionId} 初始化 SFTP 会话失败:`, err); + state.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, message: 'SFTP 初始化失败' } })); + reject(err); + } else { + console.log(`[SFTP] 为 ${connectionId} 初始化 SFTP 会话成功。`); + state.sftp = sftp; + state.ws.send(JSON.stringify({ type: 'sftp_ready', payload: { connectionId } })); + + sftp.on('end', () => { + console.log(`[SFTP] ${connectionId} 的 SFTP 会话已结束。`); + if (state) state.sftp = undefined; // 在结束时清除 SFTP 实例 + }); + sftp.on('close', () => { + console.log(`[SFTP] ${connectionId} 的 SFTP 会话已关闭。`); + if (state) state.sftp = undefined; // 在关闭时清除 SFTP 实例 + }); + sftp.on('error', (sftpErr: Error) => { // 为 sftpErr 添加 Error 类型 + console.error(`[SFTP] ${connectionId} 的 SFTP 会话出错:`, sftpErr); + if (state) state.sftp = undefined; // 在出错时清除 SFTP 实例 + // 可选:通知客户端 + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, message: 'SFTP 会话错误' } })); + }); + + resolve(); + } + }); + }); + } + + cleanupSftpSession(connectionId: string): void { + const state = this.clientStates.get(connectionId); + if (state?.sftp) { + logger.info(`[SFTP] Cleaning up SFTP session for ${connectionId}`); + state.sftp.end(); + state.sftp = undefined; + } + } + + // Placeholder methods for SFTP operations - to be implemented + async readdir(connectionId: string, path: string, requestId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sftp) { + logger.warn(`[SFTP] SFTP not ready for readdir on ${connectionId}`); + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } })); + return; + } + // Implementation to follow + logger.debug(`[SFTP] Received readdir request for ${connectionId}:${path}`); + // Example: state.sftp.readdir(...) + state.ws.send(JSON.stringify({ type: 'sftp_readdir_result', payload: { connectionId, requestId, files: [] /* Placeholder */ } })); + } + + async stat(connectionId: string, path: string, requestId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sftp) { + logger.warn(`[SFTP] SFTP not ready for stat on ${connectionId}`); + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } })); + return; + } + logger.debug(`[SFTP] Received stat request for ${connectionId}:${path}`); + // Implementation to follow + state.ws.send(JSON.stringify({ type: 'sftp_stat_result', payload: { connectionId, requestId, stats: null /* Placeholder */ } })); + } + + async readFile(connectionId: string, path: string, requestId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sftp) { + logger.warn(`[SFTP] SFTP not ready for readFile on ${connectionId}`); + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } })); + return; + } + logger.debug(`[SFTP] Received readFile request for ${connectionId}:${path}`); + // Implementation to follow + state.ws.send(JSON.stringify({ type: 'sftp_readfile_result', payload: { connectionId, requestId, data: '' /* Placeholder */ } })); + } + + async writeFile(connectionId: string, path: string, data: string, requestId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sftp) { + logger.warn(`[SFTP] SFTP not ready for writeFile on ${connectionId}`); + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } })); + return; + } + logger.debug(`[SFTP] Received writeFile request for ${connectionId}:${path}`); + // Implementation to follow + state.ws.send(JSON.stringify({ type: 'sftp_writefile_result', payload: { connectionId, requestId, success: false /* Placeholder */ } })); + } + + async mkdir(connectionId: string, path: string, requestId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sftp) { + logger.warn(`[SFTP] SFTP not ready for mkdir on ${connectionId}`); + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } })); + return; + } + logger.debug(`[SFTP] Received mkdir request for ${connectionId}:${path}`); + // Implementation to follow + state.ws.send(JSON.stringify({ type: 'sftp_mkdir_result', payload: { connectionId, requestId, success: false /* Placeholder */ } })); + } + + async rmdir(connectionId: string, path: string, requestId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sftp) { + logger.warn(`[SFTP] SFTP not ready for rmdir on ${connectionId}`); + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } })); + return; + } + logger.debug(`[SFTP] Received rmdir request for ${connectionId}:${path}`); + // Implementation to follow + state.ws.send(JSON.stringify({ type: 'sftp_rmdir_result', payload: { connectionId, requestId, success: false /* Placeholder */ } })); + } + + async unlink(connectionId: string, path: string, requestId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sftp) { + logger.warn(`[SFTP] SFTP not ready for unlink on ${connectionId}`); + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } })); + return; + } + logger.debug(`[SFTP] Received unlink request for ${connectionId}:${path}`); + // Implementation to follow + state.ws.send(JSON.stringify({ type: 'sftp_unlink_result', payload: { connectionId, requestId, success: false /* Placeholder */ } })); + } + + async rename(connectionId: string, oldPath: string, newPath: string, requestId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sftp) { + logger.warn(`[SFTP] SFTP not ready for rename on ${connectionId}`); + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } })); + return; + } + logger.debug(`[SFTP] Received rename request for ${connectionId}: ${oldPath} -> ${newPath}`); + // Implementation to follow + state.ws.send(JSON.stringify({ type: 'sftp_rename_result', payload: { connectionId, requestId, success: false /* Placeholder */ } })); + } + + async chmod(connectionId: string, path: string, mode: number, requestId: string): Promise { + const state = this.clientStates.get(connectionId); + if (!state || !state.sftp) { + logger.warn(`[SFTP] SFTP not ready for chmod on ${connectionId}`); + state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } })); + return; + } + logger.debug(`[SFTP] Received chmod request for ${connectionId}:${path} to mode ${mode.toString(8)}`); + // Implementation to follow + state.ws.send(JSON.stringify({ type: 'sftp_chmod_result', payload: { connectionId, requestId, success: false /* Placeholder */ } })); + } + + // TODO: Implement file upload/download logic with progress reporting + // async uploadFile(...) + // async downloadFile(...) + +} diff --git a/packages/backend/src/services/ssh.service.ts b/packages/backend/src/services/ssh.service.ts new file mode 100644 index 0000000..2e3f37c --- /dev/null +++ b/packages/backend/src/services/ssh.service.ts @@ -0,0 +1,433 @@ +import { Client, ClientChannel, ConnectConfig } from 'ssh2'; // Import ClientChannel and ConnectConfig +import { SocksClient, SocksClientOptions } from 'socks'; // Import SocksClientOptions +import http from 'http'; +import net from 'net'; +import WebSocket from 'ws'; // Import WebSocket for type hint +import * as ConnectionRepository from '../repositories/connection.repository'; +import { decrypt } from '../utils/crypto'; +// Import SftpService if needed later for initialization +// import * as SftpService from './sftp.service'; +// Import StatusMonitorService if needed later for initialization +// import * as StatusMonitorService from './status-monitor.service'; + + +const CONNECT_TIMEOUT = 20000; // 连接超时时间 (毫秒) +const TEST_TIMEOUT = 15000; // 测试连接超时时间 (毫秒) + +// Define AuthenticatedWebSocket interface (or import from websocket.ts if refactored there) +// This is needed to associate SSH clients with specific WS connections +interface AuthenticatedWebSocket extends WebSocket { + isAlive?: boolean; + userId?: number; + username?: string; + // sshClient?: Client; // Managed by the service now + // sshShellStream?: ClientChannel; // Managed by the service now +} + +// Structure to hold active SSH connection details managed by this service +interface ActiveSshSession { + client: Client; + shell: ClientChannel; + // sftp?: SFTPWrapper; // SFTP will be managed by SftpService + // statusIntervalId?: NodeJS.Timeout; // Status polling managed by StatusMonitorService + connectionInfo: DecryptedConnectionDetails; // Store connection info for context (Fix typo) +} + +// Map to store active sessions associated with WebSocket clients +const activeSessions = new Map(); + + +// 辅助接口:定义解密后的凭证和代理信息结构 (可以共享到 types 文件) +// Renamed to avoid conflict if imported later +interface DecryptedConnectionDetails { + id: number; + name: string; + host: string; + port: number; + username: string; + auth_method: 'password' | 'key'; + password?: string; // Decrypted + privateKey?: string; // Decrypted + passphrase?: string; // Decrypted + proxy?: { + id: number; + name: string; + type: 'SOCKS5' | 'HTTP'; + host: string; + port: number; + username?: string; + password?: string; // Decrypted + auth_method?: string; // Proxy auth method + privateKey?: string; // Decrypted proxy key + passphrase?: string; // Decrypted proxy passphrase + } | null; +} + +/** + * 测试给定 ID 的 SSH 连接(包括代理) + * @param connectionId 连接 ID + * @returns Promise - 如果连接成功则 resolve,否则 reject + * @throws Error 如果连接失败或配置错误 + */ +export const testConnection = async (connectionId: number): Promise => { + console.log(`SshService: Testing connection ${connectionId}...`); + // 1. 获取完整的连接信息(包括加密凭证和代理信息) + const rawConnInfo = await ConnectionRepository.findFullConnectionById(connectionId); // Assuming this fetches proxy details too + if (!rawConnInfo) { + throw new Error('连接配置未找到。'); + } + + // 2. 解密凭证并构建结构化的连接信息 + let fullConnInfo: DecryptedConnectionDetails; + try { + fullConnInfo = { + id: rawConnInfo.id, + name: rawConnInfo.name, + host: rawConnInfo.host, + port: rawConnInfo.port, + username: rawConnInfo.username, + auth_method: rawConnInfo.auth_method, + password: (rawConnInfo.auth_method === 'password' && rawConnInfo.encrypted_password) ? decrypt(rawConnInfo.encrypted_password) : undefined, + privateKey: (rawConnInfo.auth_method === 'key' && rawConnInfo.encrypted_private_key) ? decrypt(rawConnInfo.encrypted_private_key) : undefined, + passphrase: (rawConnInfo.auth_method === 'key' && rawConnInfo.encrypted_passphrase) ? decrypt(rawConnInfo.encrypted_passphrase) : undefined, + proxy: null, + }; + + if (rawConnInfo.proxy_db_id) { + fullConnInfo.proxy = { + id: rawConnInfo.proxy_db_id, + name: rawConnInfo.proxy_name, + type: rawConnInfo.proxy_type, + host: rawConnInfo.proxy_host, + port: rawConnInfo.proxy_port, + username: rawConnInfo.proxy_username || undefined, + auth_method: rawConnInfo.proxy_auth_method, // Include proxy auth method + password: rawConnInfo.proxy_encrypted_password ? decrypt(rawConnInfo.proxy_encrypted_password) : undefined, + privateKey: rawConnInfo.proxy_encrypted_private_key ? decrypt(rawConnInfo.proxy_encrypted_private_key) : undefined, // Decrypt proxy key + passphrase: rawConnInfo.proxy_encrypted_passphrase ? decrypt(rawConnInfo.proxy_encrypted_passphrase) : undefined, // Decrypt proxy passphrase + }; + } + } catch (decryptError: any) { + console.error(`Service: 处理连接 ${connectionId} 凭证或代理凭证失败:`, decryptError); + throw new Error(`处理凭证失败: ${decryptError.message}`); + } + + // 3. 构建 ssh2 连接配置 + const connectConfig: ConnectConfig = { // Use ConnectConfig type + host: fullConnInfo.host, + port: fullConnInfo.port, + username: fullConnInfo.username, + password: fullConnInfo.password, + privateKey: fullConnInfo.privateKey, + passphrase: fullConnInfo.passphrase, + readyTimeout: TEST_TIMEOUT, + keepaliveInterval: 0, // 测试连接不需要 keepalive + }; + + // 4. 应用代理配置并执行连接 (Refactored into helper) + const sshClient = new Client(); + try { + await establishSshConnection(sshClient, connectConfig, fullConnInfo.proxy); // Use helper + console.log(`SshService: Test connection ${connectionId} successful.`); + // Test successful, void promise resolves implicitly + } catch (error) { + console.error(`SshService: Test connection ${connectionId} failed:`, error); + throw error; // Re-throw the specific error + } finally { + // 无论成功失败,都关闭 SSH 客户端 + sshClient.end(); + console.log(`SshService: Test connection ${connectionId} client closed.`); + } +}; + + +// --- NEW FUNCTIONS FOR MANAGING LIVE CONNECTIONS --- + +/** + * Establishes an SSH connection, handling proxies. + * Internal helper function. + * @param sshClient - The ssh2 Client instance. + * @param connectConfig - Base SSH connection config. + * @param proxyInfo - Optional proxy details. + * @returns Promise that resolves when SSH is ready, or rejects on error. + */ +const establishSshConnection = ( + sshClient: Client, + connectConfig: ConnectConfig, + proxyInfo: DecryptedConnectionDetails['proxy'] +): Promise => { + return new Promise((resolve, reject) => { + const readyHandler = () => { + sshClient.removeListener('error', errorHandler); // Clean up error listener on success + resolve(); + }; + const errorHandler = (err: Error) => { + sshClient.removeListener('ready', readyHandler); // Clean up ready listener on error + reject(err); // Reject with the specific error + }; + + sshClient.once('ready', readyHandler); + sshClient.once('error', errorHandler); // Generic error handler for direct connect issues + + if (proxyInfo) { + const proxy = proxyInfo; + console.log(`SshService: Applying proxy ${proxy.name} (${proxy.type})`); + if (proxy.type === 'SOCKS5') { + const socksOptions: SocksClientOptions = { + proxy: { host: proxy.host, port: proxy.port, type: 5, userId: proxy.username, password: proxy.password }, // Type 5 is implicit + command: 'connect', + destination: { host: connectConfig.host!, port: connectConfig.port! }, // Use base config host/port + timeout: connectConfig.readyTimeout ?? CONNECT_TIMEOUT, // Use connection timeout + }; + SocksClient.createConnection(socksOptions) + .then(({ socket }) => { + console.log(`SshService: SOCKS5 proxy connection successful.`); + connectConfig.sock = socket; + sshClient.connect(connectConfig); // Connect SSH via proxy socket + }) + .catch(socksError => { + console.error(`SshService: SOCKS5 proxy connection failed:`, socksError); + // Reject the main promise, remove listeners handled by errorHandler + errorHandler(new Error(`SOCKS5 代理连接失败: ${socksError.message}`)); + }); + + } else if (proxy.type === 'HTTP') { + console.log(`SshService: Attempting HTTP proxy tunnel via ${proxy.host}:${proxy.port}...`); + const reqOptions: http.RequestOptions = { method: 'CONNECT', host: proxy.host, port: proxy.port, path: `${connectConfig.host}:${connectConfig.port}`, timeout: connectConfig.readyTimeout ?? CONNECT_TIMEOUT, 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 proxy tunnel established.`); + connectConfig.sock = socket; + sshClient.connect(connectConfig); // Connect SSH via tunnel socket + } else { + console.error(`SshService: HTTP proxy CONNECT request failed, status code: ${res.statusCode}`); + socket.destroy(); + errorHandler(new Error(`HTTP 代理连接失败 (状态码: ${res.statusCode})`)); // Reject main promise + } // <-- Added missing closing parenthesis here + }); + req.on('error', (err) => { + console.error(`SshService: HTTP proxy request error:`, err); + errorHandler(new Error(`HTTP 代理连接错误: ${err.message}`)); // Reject main promise + }); + req.on('timeout', () => { + console.error(`SshService: HTTP proxy request timeout.`); + req.destroy(); + errorHandler(new Error('HTTP 代理连接超时')); // Reject main promise + }); + req.end(); // Send the CONNECT request + } else { + errorHandler(new Error(`不支持的代理类型: ${proxy.type}`)); // Reject main promise + } + } else { + // No proxy, connect directly + console.log(`SshService: No proxy detected, connecting directly...`); + sshClient.connect(connectConfig); + } + }); +}; + + +/** + * Connects to SSH, opens a shell, and sets up event forwarding via WebSocket. + * @param connectionId - The ID of the connection config in the database. + * @param ws - The authenticated WebSocket client instance. + */ +export const connectAndOpenShell = async (connectionId: number, ws: AuthenticatedWebSocket): Promise => { + console.log(`SshService: User ${ws.username} requested connection to ID: ${connectionId}`); + if (activeSessions.has(ws)) { + console.warn(`SshService: User ${ws.username} already has an active session.`); + throw new Error('已存在活动的 SSH 连接。'); + } + + ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在获取连接信息...' })); + + // 1. Get connection info + const rawConnInfo = await ConnectionRepository.findFullConnectionById(connectionId); + if (!rawConnInfo) { + throw new Error('连接配置未找到。'); + } + + // 2. Decrypt and prepare connection details + let fullConnInfo: DecryptedConnectionDetails; + try { + // (Decryption logic similar to testConnection, could be refactored) + fullConnInfo = { /* ... decryption ... */ + id: rawConnInfo.id, name: rawConnInfo.name, host: rawConnInfo.host, port: rawConnInfo.port, username: rawConnInfo.username, auth_method: rawConnInfo.auth_method, + password: (rawConnInfo.auth_method === 'password' && rawConnInfo.encrypted_password) ? decrypt(rawConnInfo.encrypted_password) : undefined, + privateKey: (rawConnInfo.auth_method === 'key' && rawConnInfo.encrypted_private_key) ? decrypt(rawConnInfo.encrypted_private_key) : undefined, + passphrase: (rawConnInfo.auth_method === 'key' && rawConnInfo.encrypted_passphrase) ? decrypt(rawConnInfo.encrypted_passphrase) : undefined, + proxy: null, + }; + if (rawConnInfo.proxy_db_id) { + fullConnInfo.proxy = { /* ... proxy decryption ... */ + id: rawConnInfo.proxy_db_id, name: rawConnInfo.proxy_name, type: rawConnInfo.proxy_type, host: rawConnInfo.proxy_host, port: rawConnInfo.proxy_port, username: rawConnInfo.proxy_username || undefined, auth_method: rawConnInfo.proxy_auth_method, + password: rawConnInfo.proxy_encrypted_password ? decrypt(rawConnInfo.proxy_encrypted_password) : undefined, + privateKey: rawConnInfo.proxy_encrypted_private_key ? decrypt(rawConnInfo.proxy_encrypted_private_key) : undefined, + passphrase: rawConnInfo.proxy_encrypted_passphrase ? decrypt(rawConnInfo.proxy_encrypted_passphrase) : undefined, + }; + } + } catch (decryptError: any) { + console.error(`SshService: Handling credentials failed for ${connectionId}:`, decryptError); + throw new Error(`无法处理连接凭证: ${decryptError.message}`); + } + + // 3. Prepare SSH config + const connectConfig: ConnectConfig = { + host: fullConnInfo.host, + port: fullConnInfo.port, + username: fullConnInfo.username, + password: fullConnInfo.password, + privateKey: fullConnInfo.privateKey, + passphrase: fullConnInfo.passphrase, + readyTimeout: CONNECT_TIMEOUT, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + }; + + // 4. Establish connection and open shell + const sshClient = new Client(); + + // Generic error/close handlers for the client + const clientCloseHandler = () => { + console.log(`SshService: SSH client for ${ws.username} closed.`); + if (activeSessions.has(ws)) { // Check if cleanup wasn't already called + if (!ws.CLOSED && !ws.CLOSING) { + ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'SSH 连接已关闭。' })); + } + cleanupConnection(ws); // Ensure cleanup + } + }; + const clientErrorHandler = (err: Error) => { + console.error(`SshService: SSH client error for ${ws.username}:`, err); + if (activeSessions.has(ws)) { // Check if cleanup wasn't already called + if (!ws.CLOSED && !ws.CLOSING) { + ws.send(JSON.stringify({ type: 'ssh:error', payload: `SSH 连接错误: ${err.message}` })); + } + cleanupConnection(ws); // Ensure cleanup + } + }; + sshClient.on('close', clientCloseHandler); + sshClient.on('error', clientErrorHandler); + + + try { + ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在连接到 ${fullConnInfo.host}...` })); + await establishSshConnection(sshClient, connectConfig, fullConnInfo.proxy); // Use helper + + ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' })); + + // 5. Open Shell Stream + const shellStream = await new Promise((resolve, reject) => { + sshClient.shell((err, stream) => { + if (err) { + console.error(`SshService: User ${ws.username} failed to open shell:`, err); + return reject(new Error(`打开 Shell 失败: ${err.message}`)); + } + console.log(`SshService: User ${ws.username} shell channel opened.`); + resolve(stream); + }); + }); + + // 6. Store active session + const session: ActiveSshSession = { client: sshClient, shell: shellStream, connectionInfo: fullConnInfo }; + activeSessions.set(ws, session); + console.log(`SshService: Active session stored for ${ws.username}.`); + + // 7. Setup event forwarding for the shell stream + shellStream.on('data', (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); + } + }); + shellStream.stderr.on('data', (data: Buffer) => { + console.error(`SSH Stderr (${ws.username}): ${data.toString('utf8').substring(0,100)}...`); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); // Send stderr as output + } + }); + shellStream.on('close', () => { + console.log(`SshService: Shell stream for ${ws.username} closed.`); + if (activeSessions.has(ws)) { // Check if cleanup wasn't already called by client close + if (!ws.CLOSED && !ws.CLOSING) { + ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' })); + } + cleanupConnection(ws); // Trigger cleanup if shell closes independently + } + }); + + // 8. Initialize SFTP (TODO: Move to SftpService) and Status Polling (TODO: Move to StatusMonitorService) + // For now, just notify connection success + ws.send(JSON.stringify({ type: 'ssh:connected' })); + + // TODO: Call SftpService.initializeSftpSession(ws, sshClient); + // TODO: Call StatusMonitorService.startStatusPolling(ws, sshClient); + + + } catch (error: any) { + console.error(`SshService: Failed to connect or open shell for ${ws.username}:`, error); + // Ensure client listeners are removed and client is ended on failure + sshClient.removeListener('close', clientCloseHandler); + sshClient.removeListener('error', clientErrorHandler); + sshClient.end(); + cleanupConnection(ws); // Clean up any partial state + throw error; // Re-throw for the controller + } +}; + +/** + * Sends input data to the SSH shell stream associated with a WebSocket connection. + * @param ws - The authenticated WebSocket client. + * @param data - The data string to send. + */ +export const sendInput = (ws: AuthenticatedWebSocket, data: string): void => { + const session = activeSessions.get(ws); + if (session?.shell && session.shell.writable) { + session.shell.write(data); + } else { + console.warn(`SshService: Cannot send input for ${ws.username}, no active/writable shell stream found.`); + // Optionally notify the client ws.send(...) + } +}; + +/** + * Resizes the pseudo-terminal associated with a WebSocket connection. + * @param ws - The authenticated WebSocket client. + * @param cols - Terminal width in columns. + * @param rows - Terminal height in rows. + */ +export const resizeTerminal = (ws: AuthenticatedWebSocket, cols: number, rows: number): void => { + const session = activeSessions.get(ws); + if (session?.shell) { + console.log(`SshService: Resizing terminal for ${ws.username} to ${cols}x${rows}`); + session.shell.setWindow(rows, cols, 0, 0); // Note: rows, cols order + } else { + console.warn(`SshService: Cannot resize terminal for ${ws.username}, no active shell stream found.`); + } +}; + +/** + * Cleans up SSH resources associated with a WebSocket connection. + * @param ws - The authenticated WebSocket client. + */ +export const cleanupConnection = (ws: AuthenticatedWebSocket): void => { + const session = activeSessions.get(ws); + if (session) { + console.log(`SshService: Cleaning up SSH session for ${ws.username}...`); + // TODO: Call StatusMonitorService.stopStatusPolling(ws); + // TODO: Call SftpService.cleanupSftpSession(ws); + + // End streams and client + session.shell?.end(); // End the shell stream first + session.client?.end(); // End the main SSH client connection + + activeSessions.delete(ws); // Remove from active sessions map + console.log(`SshService: SSH session for ${ws.username} cleaned up.`); + } else { + // console.log(`SshService: No active SSH session found for ${ws.username} during cleanup.`); + } +}; diff --git a/packages/backend/src/services/tag.service.ts b/packages/backend/src/services/tag.service.ts new file mode 100644 index 0000000..36c4792 --- /dev/null +++ b/packages/backend/src/services/tag.service.ts @@ -0,0 +1,79 @@ +import * as TagRepository from '../repositories/tag.repository'; + +// Re-export or define types +export interface TagData extends TagRepository.TagData {} + +/** + * 获取所有标签 + */ +export const getAllTags = async (): Promise => { + return TagRepository.findAllTags(); +}; + +/** + * 根据 ID 获取单个标签 + */ +export const getTagById = async (id: number): Promise => { + return TagRepository.findTagById(id); +}; + +/** + * 创建新标签 + */ +export const createTag = async (name: string): Promise => { + // 1. Validate input + if (!name || name.trim().length === 0) { + throw new Error('标签名称不能为空。'); + } + const trimmedName = name.trim(); + + // 2. Create tag record + try { + const newTagId = await TagRepository.createTag(trimmedName); + // 3. Fetch and return the newly created tag + const newTag = await getTagById(newTagId); + if (!newTag) { + throw new Error('创建标签后无法检索到该标签。'); + } + return newTag; + } catch (error: any) { + if (error.message.includes('UNIQUE constraint failed')) { + throw new Error(`创建标签失败:标签名称 "${trimmedName}" 已存在。`); + } + throw error; // Re-throw other errors + } +}; + +/** + * 更新标签名称 + */ +export const updateTag = async (id: number, name: string): Promise => { + // 1. Validate input + if (!name || name.trim().length === 0) { + throw new Error('标签名称不能为空。'); + } + const trimmedName = name.trim(); + + // 2. Update tag record + try { + const updated = await TagRepository.updateTag(id, trimmedName); + if (!updated) { + return null; // Tag not found or not updated + } + // 3. Fetch and return the updated tag + return getTagById(id); + } catch (error: any) { + if (error.message.includes('UNIQUE constraint failed')) { + throw new Error(`更新标签失败:标签名称 "${trimmedName}" 已存在。`); + } + throw error; // Re-throw other errors + } +}; + +/** + * 删除标签 + */ +export const deleteTag = async (id: number): Promise => { + // Repository handles cascading deletes in connection_tags + return TagRepository.deleteTag(id); +}; diff --git a/packages/backend/src/tags/tags.controller.ts b/packages/backend/src/tags/tags.controller.ts index 10113b3..11abd87 100644 --- a/packages/backend/src/tags/tags.controller.ts +++ b/packages/backend/src/tags/tags.controller.ts @@ -1,59 +1,27 @@ import { Request, Response } from 'express'; -import { Statement } from 'sqlite3'; -import { getDb } from '../database'; - -const db = getDb(); - -// 标签数据结构 (用于类型提示) -interface TagInfo { - id: number; - name: string; - created_at: number; - updated_at: number; -} +import * as TagService from '../services/tag.service'; /** * 创建新标签 (POST /api/v1/tags) */ export const createTag = async (req: Request, res: Response): Promise => { const { name } = req.body; - const userId = req.session.userId; // 保留以备将来多用户支持 if (!name || typeof name !== 'string' || name.trim() === '') { res.status(400).json({ message: '标签名称不能为空。' }); return; } - const tagName = name.trim(); - const now = Math.floor(Date.now() / 1000); - try { - // 插入数据库,name 字段有 UNIQUE 约束,重复会报错 - const result = await new Promise<{ lastID: number }>((resolve, reject) => { - const stmt = db.prepare( - `INSERT INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)` - ); - stmt.run(tagName, now, now, function (this: Statement, err: Error | null) { - if (err) { - if (err.message.includes('UNIQUE constraint failed')) { - return reject(new Error(`标签 "${tagName}" 已存在。`)); - } - console.error('插入标签时出错:', err.message); - return reject(new Error('创建标签失败')); - } - resolve({ lastID: (this as any).lastID }); - }); - stmt.finalize(); - }); - - res.status(201).json({ - message: '标签创建成功。', - tag: { id: result.lastID, name: tagName, created_at: now, updated_at: now } - }); - + const newTag = await TagService.createTag(name); + res.status(201).json({ message: '标签创建成功。', tag: newTag }); } catch (error: any) { - console.error('创建标签时发生错误:', error); - res.status(500).json({ message: error.message || '创建标签时发生内部服务器错误。' }); + console.error('Controller: 创建标签时发生错误:', error); + if (error.message.includes('已存在')) { + res.status(409).json({ message: error.message }); // Conflict + } else { + res.status(500).json({ message: error.message || '创建标签时发生内部服务器错误。' }); + } } }; @@ -61,24 +29,11 @@ export const createTag = async (req: Request, res: Response): Promise => { * 获取标签列表 (GET /api/v1/tags) */ export const getTags = async (req: Request, res: Response): Promise => { - const userId = req.session.userId; // 保留 - try { - const tags = await new Promise((resolve, reject) => { - db.all( - `SELECT id, name, created_at, updated_at FROM tags ORDER BY name ASC`, - (err, rows: TagInfo[]) => { - if (err) { - console.error('查询标签列表时出错:', err.message); - return reject(new Error('获取标签列表失败')); - } - resolve(rows); - } - ); - }); + const tags = await TagService.getAllTags(); res.status(200).json(tags); } catch (error: any) { - console.error('获取标签列表时发生错误:', error); + console.error('Controller: 获取标签列表时发生错误:', error); res.status(500).json({ message: error.message || '获取标签列表时发生内部服务器错误。' }); } }; @@ -88,7 +43,6 @@ export const getTags = async (req: Request, res: Response): Promise => { */ export const getTagById = async (req: Request, res: Response): Promise => { const tagId = parseInt(req.params.id, 10); - const userId = req.session.userId; // 保留 if (isNaN(tagId)) { res.status(400).json({ message: '无效的标签 ID。' }); @@ -96,27 +50,14 @@ export const getTagById = async (req: Request, res: Response): Promise => } try { - const tag = await new Promise((resolve, reject) => { - db.get( - `SELECT id, name, created_at, updated_at FROM tags WHERE id = ?`, - [tagId], - (err, row: TagInfo) => { - if (err) { - console.error(`查询标签 ${tagId} 时出错:`, err.message); - return reject(new Error('获取标签信息失败')); - } - resolve(row || null); - } - ); - }); - + const tag = await TagService.getTagById(tagId); if (!tag) { res.status(404).json({ message: '标签未找到。' }); } else { res.status(200).json(tag); } } catch (error: any) { - console.error(`获取标签 ${tagId} 时发生错误:`, error); + console.error(`Controller: 获取标签 ${tagId} 时发生错误:`, error); res.status(500).json({ message: error.message || '获取标签信息时发生内部服务器错误。' }); } }; @@ -127,7 +68,6 @@ export const getTagById = async (req: Request, res: Response): Promise => export const updateTag = async (req: Request, res: Response): Promise => { const tagId = parseInt(req.params.id, 10); const { name } = req.body; - const userId = req.session.userId; // 保留 if (isNaN(tagId)) { res.status(400).json({ message: '无效的标签 ID。' }); @@ -138,43 +78,23 @@ export const updateTag = async (req: Request, res: Response): Promise => { return; } - const tagName = name.trim(); - const now = Math.floor(Date.now() / 1000); - try { - const result = await new Promise<{ changes: number }>((resolve, reject) => { - const stmt = db.prepare( - `UPDATE tags SET name = ?, updated_at = ? WHERE id = ?` - ); - stmt.run(tagName, now, tagId, function (this: Statement, err: Error | null) { - if (err) { - if (err.message.includes('UNIQUE constraint failed')) { - return reject(new Error(`标签名称 "${tagName}" 已存在。`)); - } - console.error(`更新标签 ${tagId} 时出错:`, err.message); - return reject(new Error('更新标签失败')); - } - resolve({ changes: (this as any).changes }); - }); - stmt.finalize(); - }); - - if (result.changes === 0) { - res.status(404).json({ message: '标签未找到或名称未更改。' }); + const updatedTag = await TagService.updateTag(tagId, name); + if (!updatedTag) { + res.status(404).json({ message: '标签未找到。' }); } else { - // 获取更新后的信息并返回 - const updatedTag = await new Promise((resolve, reject) => { - db.get( - `SELECT id, name, created_at, updated_at FROM tags WHERE id = ?`, - [tagId], - (err, row: TagInfo) => err ? reject(err) : resolve(row || null) - ); - }); res.status(200).json({ message: '标签更新成功。', tag: updatedTag }); } } catch (error: any) { - console.error(`更新标签 ${tagId} 时发生错误:`, error); - res.status(500).json({ message: error.message || '更新标签时发生内部服务器错误。' }); + console.error(`Controller: 更新标签 ${tagId} 时发生错误:`, error); + if (error.message.includes('已存在')) { + res.status(409).json({ message: error.message }); // Conflict + } else if (error.message.includes('不能为空')) { + res.status(400).json({ message: error.message }); + } + else { + res.status(500).json({ message: error.message || '更新标签时发生内部服务器错误。' }); + } } }; @@ -183,7 +103,6 @@ export const updateTag = async (req: Request, res: Response): Promise => { */ export const deleteTag = async (req: Request, res: Response): Promise => { const tagId = parseInt(req.params.id, 10); - const userId = req.session.userId; // 保留 if (isNaN(tagId)) { res.status(400).json({ message: '无效的标签 ID。' }); @@ -191,31 +110,14 @@ export const deleteTag = async (req: Request, res: Response): Promise => { } try { - // TODO: 在删除标签前,需要考虑处理 connection_tags 关联表中的数据 - // 例如:可以选择删除关联记录,或者阻止删除有关联的标签 - // 当前简化处理:直接删除标签 - - const result = await new Promise<{ changes: number }>((resolve, reject) => { - const stmt = db.prepare( - `DELETE FROM tags WHERE id = ?` - ); - stmt.run(tagId, function (this: Statement, err: Error | null) { - if (err) { - console.error(`删除标签 ${tagId} 时出错:`, err.message); - return reject(new Error('删除标签失败')); - } - resolve({ changes: (this as any).changes }); - }); - stmt.finalize(); - }); - - if (result.changes === 0) { + const deleted = await TagService.deleteTag(tagId); + if (!deleted) { res.status(404).json({ message: '标签未找到。' }); } else { res.status(200).json({ message: '标签删除成功。' }); } } catch (error: any) { - console.error(`删除标签 ${tagId} 时发生错误:`, error); + console.error(`Controller: 删除标签 ${tagId} 时发生错误:`, error); res.status(500).json({ message: error.message || '删除标签时发生内部服务器错误。' }); } };