diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index 2e7f4f5..782f5d0 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -507,5 +507,246 @@ export const deleteConnection = 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: '连接配置未找到。' }); + return; + } + + // 2. 构建包含解密凭证和代理对象的 FullConnectionInfo + const fullConnInfo: FullConnectionInfo = { + ...connInfo, // 包含 id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at + proxy: null, // 初始化 proxy + }; + + 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(); + } + + } catch (error: any) { + console.error(`测试连接 ${connectionId} 时发生内部错误:`, error); + res.status(500).json({ success: false, message: error.message || '测试连接时发生内部服务器错误。' }); + } +}; diff --git a/packages/backend/src/connections/connections.routes.ts b/packages/backend/src/connections/connections.routes.ts index 67f0d3c..9416860 100644 --- a/packages/backend/src/connections/connections.routes.ts +++ b/packages/backend/src/connections/connections.routes.ts @@ -5,7 +5,8 @@ import { getConnections, getConnectionById, // 引入获取单个连接的控制器 updateConnection, // 引入更新连接的控制器 - deleteConnection // 引入删除连接的控制器 + deleteConnection, // 引入删除连接的控制器 + testConnection // 引入测试连接的控制器 } from './connections.controller'; const router = Router(); @@ -26,9 +27,9 @@ router.get('/:id', getConnectionById); router.put('/:id', updateConnection); // DELETE /api/v1/connections/:id - 删除连接 -router.delete('/:id', deleteConnection); // 使用占位符 +router.delete('/:id', deleteConnection); -// TODO: 添加测试连接路由 -// router.post('/:id/test', testConnection); +// POST /api/v1/connections/:id/test - 测试连接 +router.post('/:id/test', testConnection); export default router; diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 1ecd85e..a6c506c 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -4,10 +4,12 @@ import { Request, RequestHandler } from 'express'; import { Client, ClientChannel, SFTPWrapper, Stats } from 'ssh2'; // 引入 SFTPWrapper 和 Stats import { WriteStream } from 'fs'; // 需要 WriteStream 类型 (虽然 ssh2 的流类型不同,但可以借用) import { getDb } from './database'; // 引入数据库实例 -import { decrypt } from './utils/crypto'; // 引入解密函数 -import path from 'path'; // 需要 path -import { HttpsProxyAgent } from 'https-proxy-agent'; // 引入 HTTP 代理支持 -import { SocksClient } from 'socks'; // 引入 SOCKS 代理支持 + import { decrypt } from './utils/crypto'; // 引入解密函数 + import path from 'path'; // 需要 path + // import { HttpsProxyAgent } from 'https-proxy-agent'; // 不再直接使用 HttpsProxyAgent for SSH tunneling + import { SocksClient } from 'socks'; // 引入 SOCKS 代理支持 + // import http from 'http'; // 重复导入,保留上面的 + import net from 'net'; // 引入 net 用于 Socket 类型 // 扩展 WebSocket 类型以包含会话和 SSH/SFTP 连接信息 interface AuthenticatedWebSocket extends WebSocket { @@ -642,20 +644,57 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH // 注意:对于 SOCKS5,连接逻辑在 .then 回调中处理 } else if (proxyInfo.type === 'HTTP') { - let proxyUrl = `http://`; + console.log(`WebSocket: 尝试通过 HTTP 代理 ${proxyInfo.host}:${proxyInfo.port} 建立隧道...`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在通过 HTTP 代理 ${proxyInfo.name} 建立隧道...` })); + + // 手动发起 CONNECT 请求 + const reqOptions: http.RequestOptions = { + method: 'CONNECT', + host: proxyInfo.host, + port: proxyInfo.port, + path: `${connInfo.host}:${connInfo.port}`, // 目标 SSH 服务器地址和端口 + timeout: connectConfig.readyTimeout ?? 20000, + agent: false, // 不使用全局 agent + }; + // 添加代理认证头部 (如果需要) if (proxyInfo.username) { - proxyUrl += `${proxyInfo.username}`; - if (proxyPassword) { - proxyUrl += `:${proxyPassword}`; - } - proxyUrl += '@'; + const auth = 'Basic ' + Buffer.from(proxyInfo.username + ':' + (proxyPassword || '')).toString('base64'); + reqOptions.headers = { + ...reqOptions.headers, + 'Proxy-Authorization': auth, + 'Proxy-Connection': 'Keep-Alive', // 某些代理需要 + 'Host': `${connInfo.host}:${connInfo.port}` // CONNECT 请求的目标 + }; } - proxyUrl += `${proxyInfo.host}:${proxyInfo.port}`; - console.log(`WebSocket: 为连接 ${connInfo.id} 配置 HTTP 代理: ${proxyUrl.replace(/:[^:]*@/, ':***@')}`); - connectConfig.agent = new HttpsProxyAgent(proxyUrl); - console.log(`WebSocket: 已配置 HTTP 代理。正在建立 SSH 连接...`); - ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在通过 HTTP 代理 ${proxyInfo.name} 连接...` })); - connectSshClient(ws, sshClient, connectConfig, connInfo); // 通过代理连接 SSH + + const req = http.request(reqOptions); + req.on('connect', (res, socket, head) => { + if (res.statusCode === 200) { + console.log(`WebSocket: HTTP 代理隧道建立成功。正在建立 SSH 连接...`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: 'HTTP 代理隧道成功,正在建立 SSH...' })); + connectConfig.sock = socket; // 使用建立的隧道 socket + connectSshClient(ws, sshClient, connectConfig, connInfo); // 通过隧道连接 SSH + } else { + console.error(`WebSocket: HTTP 代理 CONNECT 请求失败, 状态码: ${res.statusCode}`); + socket.destroy(); + ws.send(JSON.stringify({ type: 'ssh:error', payload: `HTTP 代理连接失败 (状态码: ${res.statusCode})` })); + cleanupSshConnection(ws); + } + }); + req.on('error', (err) => { + console.error(`WebSocket: HTTP 代理请求错误:`, err); + ws.send(JSON.stringify({ type: 'ssh:error', payload: `HTTP 代理连接错误: ${err.message}` })); + cleanupSshConnection(ws); + }); + req.on('timeout', () => { + console.error(`WebSocket: HTTP 代理请求超时`); + req.destroy(); // 销毁请求 + ws.send(JSON.stringify({ type: 'ssh:error', payload: 'HTTP 代理连接超时' })); + cleanupSshConnection(ws); + }); + req.end(); // 发送请求 + // 注意:对于 HTTP 代理,连接逻辑在 'connect' 事件回调中处理 + } else { console.error(`WebSocket: 未知的代理类型: ${proxyInfo.type}`); ws.send(JSON.stringify({ type: 'ssh:error', payload: `未知的代理类型: ${proxyInfo.type}` })); diff --git a/packages/data/nexus-terminal.db b/packages/data/nexus-terminal.db index 3ab3259..ba743d1 100644 Binary files a/packages/data/nexus-terminal.db and b/packages/data/nexus-terminal.db differ diff --git a/packages/frontend/src/components/ConnectionList.vue b/packages/frontend/src/components/ConnectionList.vue index 400ca2d..8f0b838 100644 --- a/packages/frontend/src/components/ConnectionList.vue +++ b/packages/frontend/src/components/ConnectionList.vue @@ -1,25 +1,32 @@ + // 新增:处理测试连接的方法 + const handleTestConnection = async (connectionId: number) => { + const connectionsStore = useConnectionsStore(); // 获取 store 实例 + testingState[connectionId] = true; // 设置为正在测试状态 + const result = await connectionsStore.testConnection(connectionId); // 调用 store action + testingState[connectionId] = false; // 清除测试状态 + + // 显示测试结果 + if (result.success) { + alert(t('connections.test.success')); + } else { + alert(t('connections.test.failed', { error: result.message || '未知错误' })); + } + }; + + @@ -132,7 +234,19 @@ export default { margin-top: 1rem; } -.loading, .error, .no-connections { +.connection-group { + margin-bottom: 1.5rem; /* 分组间距 */ +} + +.group-title { + margin-bottom: 0.5rem; + font-size: 1.1em; + font-weight: bold; + border-bottom: 1px solid #eee; + padding-bottom: 0.3rem; +} + +.loading, .error, .no-connections, .no-connections-in-group { padding: 1rem; border: 1px solid #ccc; border-radius: 4px; @@ -170,6 +284,38 @@ button { cursor: pointer; } +.action-button { /* 统一按钮样式 */ + padding: 0.3rem 0.6rem; + margin-right: 0.5rem; + cursor: pointer; + border: none; + border-radius: 4px; + font-size: 0.9em; + min-width: 50px; /* 给按钮一个最小宽度 */ + text-align: center; +} +.connect-button { + background-color: #28a745; /* Green */ + color: white; +} +.edit-button { + background-color: #ffc107; /* Amber */ + color: #333; +} +.test-button { + background-color: #17a2b8; /* Teal */ + color: white; +} +.delete-button { + background-color: #dc3545; /* Red */ + color: white; +} +.action-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + + /* 标签样式 */ .tag-list { display: flex; diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index cff694d..7487ff0 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -35,7 +35,9 @@ "actions": { "connect": "Connect", "edit": "Edit", - "delete": "Delete" + "delete": "Delete", + "test": "Test", + "testing": "Testing..." }, "form": { "title": "Add New Connection", @@ -77,6 +79,13 @@ }, "status": { "never": "Never" + }, + "filterAllTags": "All Tags", + "untaggedGroup": "Untagged", + "noUntaggedConnections": "No untagged connections found.", + "test": { + "success": "Connection test successful!", + "failed": "Connection test failed: {error}" } }, "proxies": { diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index e97ceba..f860f85 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -35,7 +35,9 @@ "actions": { "connect": "连接", "edit": "编辑", - "delete": "删除" + "delete": "删除", + "test": "测试", + "testing": "测试中..." }, "form": { "title": "添加新连接", @@ -77,6 +79,13 @@ }, "status": { "never": "从未" + }, + "filterAllTags": "所有标签", + "untaggedGroup": "未标记", + "noUntaggedConnections": "没有未标记的连接。", + "test": { + "success": "连接测试成功!", + "failed": "连接测试失败: {error}" } }, "proxies": { diff --git a/packages/frontend/src/stores/connections.store.ts b/packages/frontend/src/stores/connections.store.ts index 483ca20..f48ad4d 100644 --- a/packages/frontend/src/stores/connections.store.ts +++ b/packages/frontend/src/stores/connections.store.ts @@ -141,5 +141,26 @@ export const useConnectionsStore = defineStore('connections', { this.isLoading = false; } }, + + // 新增:测试连接 Action + async testConnection(connectionId: number): Promise<{ success: boolean; message?: string }> { + // 注意:这里不改变 isLoading 状态,或者可以引入单独的 testing 状态 + // this.isLoading = true; + // this.error = null; + try { + const response = await axios.post<{ success: boolean; message: string }>(`/api/v1/connections/${connectionId}/test`); + return { success: response.data.success, message: response.data.message }; + } catch (err: any) { + console.error(`测试连接 ${connectionId} 失败:`, err); + const errorMessage = err.response?.data?.message || err.message || '测试连接时发生未知错误。'; + if (err.response?.status === 401) { + console.warn('未授权,需要登录才能测试连接。'); + } + // 返回失败状态和错误消息 + return { success: false, message: errorMessage }; + } finally { + // this.isLoading = false; + } + }, }, }); diff --git a/packages/frontend/src/views/ConnectionsView.vue b/packages/frontend/src/views/ConnectionsView.vue index a0f2fdf..505e41e 100644 --- a/packages/frontend/src/views/ConnectionsView.vue +++ b/packages/frontend/src/views/ConnectionsView.vue @@ -1,17 +1,42 @@