update
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
# RDP 集成计划
|
||||||
|
|
||||||
|
## 整体架构思路
|
||||||
|
|
||||||
|
我们将采用与 `test-rdp` 类似的架构:
|
||||||
|
|
||||||
|
1. **前端 (`packages/frontend`):** 使用 `guacamole-common-js` 库在 `RemoteDesktopModal.vue` 组件中渲染 RDP 界面并处理用户输入。
|
||||||
|
2. **后端 API (`packages/backend`):** 提供一个接口,根据连接 ID 从数据库获取连接信息,并生成一个加密的 Guacamole 连接令牌 (`token`)。
|
||||||
|
3. **WebSocket 代理:** 需要一个服务来处理前端和 `guacd` 之间的 WebSocket 通信。我们将采用 `test-rdp` 中的 `guacamole-lite` 方案,但将其作为一个**独立的服务**运行,与主后端分开。
|
||||||
|
4. **Guacamole Daemon (`guacd`):** 这是 Guacamole 的核心组件,需要单独运行,负责实际的 RDP 协议转换。
|
||||||
|
|
||||||
|
## 详细计划步骤
|
||||||
|
|
||||||
|
### 1. 后端 (`packages/backend`)
|
||||||
|
|
||||||
|
* **创建 Guacamole 服务 (`guacamole.service.ts`):**
|
||||||
|
* 在 `packages/backend/src/services/` 目录下创建新文件 `guacamole.service.ts`。
|
||||||
|
* 实现一个 `generateConnectionToken` 方法:
|
||||||
|
* 接收 `ConnectionInfo` 对象和加密密钥作为参数。
|
||||||
|
* 将 `ConnectionInfo` 构造成 Guacamole 所需的 JSON 结构(包含 `type: 'rdp'`, `settings: {...}`)。
|
||||||
|
* 使用 `crypto` 模块和 `AES-256-CBC` 算法,以及一个 32 字节的加密密钥(从配置加载)来加密这个 JSON 字符串。
|
||||||
|
* 返回 Base64 编码后的加密令牌(格式需与 `guacamole-lite` 解密时兼容,参考 `test-rdp/backend/src/server.ts` 的 `encryptToken` 函数)。
|
||||||
|
* **添加 API 端点 (`connections.controller.ts`, `connections.routes.ts`):**
|
||||||
|
* 在 `ConnectionsController` 中添加一个新方法,例如 `getRdpToken`。
|
||||||
|
* 添加一个新的路由,例如 `GET /api/connections/:id/rdp-token`,指向 `getRdpToken` 方法。
|
||||||
|
* `getRdpToken` 方法:
|
||||||
|
* 从请求参数中获取 `id`。
|
||||||
|
* 注入并使用 `ConnectionService` 根据 `id` 获取完整的 `ConnectionInfo`(包括主机、端口、用户名、密码等)。
|
||||||
|
* 注入并使用新创建的 `GuacamoleService` 的 `generateConnectionToken` 方法生成令牌。
|
||||||
|
* 将生成的令牌以 JSON 格式 (`{ token: "..." }`) 返回给前端。
|
||||||
|
* **配置:**
|
||||||
|
* 在后端配置中(例如通过环境变量 `.env` 或配置文件)定义 `GUACAMOLE_ENCRYPTION_KEY`。**这个密钥必须是 32 字节长**,并且需要与后面运行的 `guacamole-lite` 服务使用的密钥相同。
|
||||||
|
* 确保后端能正确加载和使用这个密钥。
|
||||||
|
* **依赖:**
|
||||||
|
* 确保 `packages/backend/package.json` 中包含 `crypto` (Node.js 内置)。
|
||||||
|
|
||||||
|
### 2. 前端 (`packages/frontend`)
|
||||||
|
|
||||||
|
* **`RemoteDesktopModal.vue`:**
|
||||||
|
* **Props:** 确认组件接收 `connection: ConnectionInfo` 作为 prop。
|
||||||
|
* **模板:** 在 `<template>` 中添加一个 `div` 用于显示 RDP 内容,并给它一个 `ref`,例如 `<div ref="rdpDisplayRef" class="rdp-display-container"></div>`。确保这个容器能正确填充模态框区域。
|
||||||
|
* **脚本 (`<script setup>`):**
|
||||||
|
* 导入 `ref`, `onMounted`, `onUnmounted`, `watch` 等 Vue API。
|
||||||
|
* 导入 `Guacamole` from `guacamole-common-js`。
|
||||||
|
* 导入 `apiClient` (或其他用于 API 请求的工具)。
|
||||||
|
* 定义状态变量:`guacClient = ref<Guacamole.Client | null>(null)`, `connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected')`, `statusMessage = ref('')` 等。
|
||||||
|
* 定义 `rdpDisplayRef = ref<HTMLDivElement | null>(null)`。
|
||||||
|
* **连接逻辑 (例如 `connectRdp` 方法):**
|
||||||
|
* 当模态框显示且 `props.connection` 有效时触发。
|
||||||
|
* 设置状态为 `connecting`。
|
||||||
|
* 调用后端 API `GET /api/connections/${props.connection.id}/rdp-token` 获取令牌。
|
||||||
|
* 处理 API 错误。
|
||||||
|
* **获取 WebSocket URL:** 需要确定 `guacamole-lite` 服务的地址和端口 (例如 `ws://localhost:8081`)。这可以通过配置或从后端获取。
|
||||||
|
* 构造完整的 WebSocket URL: `const tunnelUrl = \`ws://<GUAC_LITE_HOST>:<GUAC_LITE_PORT>/?token=\${encodeURIComponent(token)}\`;`
|
||||||
|
* 创建 `Guacamole.WebSocketTunnel(tunnelUrl)`。
|
||||||
|
* 创建 `Guacamole.Client(tunnel)`。
|
||||||
|
* 将 `guacClient.value.getDisplay().getElement()` 添加到 `rdpDisplayRef.value` 中。
|
||||||
|
* 设置 `onstatechange`, `onerror`, `oninstruction` 等事件处理器(参考 `test-rdp/frontend/src/App.vue`)。
|
||||||
|
* 设置鼠标 (`Guacamole.Mouse`) 和键盘 (`Guacamole.Keyboard`) 事件监听器,并将事件发送给 `guacClient.value`。
|
||||||
|
* 调用 `guacClient.value.connect()`。
|
||||||
|
* **断开连接逻辑 (例如 `disconnectRdp` 方法):**
|
||||||
|
* 在 `closeModal` 函数中或 `onUnmounted` 钩子中调用。
|
||||||
|
* 检查 `guacClient.value` 是否存在并已连接。
|
||||||
|
* 调用 `guacClient.value.disconnect()`。
|
||||||
|
* 清理 `rdpDisplayRef.value` 的内容。
|
||||||
|
* 重置状态变量。
|
||||||
|
* **依赖:**
|
||||||
|
* 运行 `npm install guacamole-common-js` 或 `yarn add guacamole-common-js` 在 `packages/frontend` 目录下。
|
||||||
|
* **类型定义:**
|
||||||
|
* 检查 `packages/frontend/src/types/guacamole.d.ts` 是否存在且内容充分。如果不存在,需要创建它并添加必要的类型声明,或者至少在使用 `Guacamole` 对象时使用 `@ts-ignore` 或 `any` (不推荐)。
|
||||||
|
|
||||||
|
### 3. WebSocket 代理 (`guacamole-lite` 服务)
|
||||||
|
|
||||||
|
* **独立运行:** 将 `test-rdp/backend` 的代码(主要是 `server.ts` 和 `package.json`)复制到一个新的目录,或者调整它以便可以独立运行。
|
||||||
|
* **配置:**
|
||||||
|
* 确保它使用的 `ENCRYPTION_KEY_STRING` 与 `packages/backend` 配置的 `GUACAMOLE_ENCRYPTION_KEY` **完全相同**。
|
||||||
|
* 配置 `GUACD_HOST` 和 `GUACD_PORT` 指向你实际运行的 `guacd` 服务。
|
||||||
|
* 配置 `GUAC_WS_PORT` (例如 `8081`),前端将连接到这个端口。
|
||||||
|
* **移除 API 部分:** 这个独立的服务**不需要** `test-rdp/backend/server.ts` 中的 Express API (`/api/get-token`) 部分,因为它只负责 WebSocket 代理。主项目的后端会处理令牌生成。
|
||||||
|
* **运行方式:** 使用 `node` 或 `pm2` 等工具运行这个 `guacamole-lite` 服务。
|
||||||
|
|
||||||
|
### 4. Guacamole Daemon (`guacd`)
|
||||||
|
|
||||||
|
* 你需要一个正在运行的 `guacd` 服务实例。这通常通过 Docker 镜像 (`guacamole/guacd`) 来部署。
|
||||||
|
* 确保 `guacamole-lite` 服务可以访问到 `guacd` 的主机和端口。
|
||||||
|
|
||||||
|
## 时序图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant FE (Frontend - WorkspaceConnectionList)
|
||||||
|
participant RDM (Frontend - RemoteDesktopModal)
|
||||||
|
participant MainAPI (Backend - Main API)
|
||||||
|
participant GuacSvc (Backend - GuacamoleService)
|
||||||
|
participant ConnRepo (Backend - ConnectionRepository)
|
||||||
|
participant GuacLite (WebSocket Proxy - guacamole-lite)
|
||||||
|
participant Guacd (Guacamole Daemon)
|
||||||
|
participant RemoteHost (RDP Server)
|
||||||
|
|
||||||
|
FE->>RDM: User clicks RDP connection (pass ConnectionInfo)
|
||||||
|
RDM->>RDM: Modal opens, receives ConnectionInfo prop
|
||||||
|
RDM->>MainAPI: GET /api/connections/{id}/rdp-token
|
||||||
|
MainAPI->>ConnRepo: Fetch connection details for {id}
|
||||||
|
ConnRepo-->>MainAPI: Return connection details (host, port, user, pass etc.)
|
||||||
|
MainAPI->>GuacSvc: Generate token(connection details, encryption_key)
|
||||||
|
GuacSvc-->>MainAPI: Return encrypted token
|
||||||
|
MainAPI-->>RDM: Return { token: "..." }
|
||||||
|
RDM->>GuacLite: WebSocket Connect (ws://<GUAC_LITE_HOST>:<GUAC_LITE_PORT>/?token=...)
|
||||||
|
GuacLite->>GuacLite: Decrypt token (using same encryption_key)
|
||||||
|
GuacLite->>Guacd: Connect (using decrypted params: host, port, user, pass...)
|
||||||
|
Guacd->>RemoteHost: Initiate RDP connection
|
||||||
|
RemoteHost-->>Guacd: RDP data stream
|
||||||
|
Guacd-->>GuacLite: Guacamole protocol stream
|
||||||
|
GuacLite-->>RDM: Guacamole protocol stream (via WebSocket)
|
||||||
|
RDM->>RDM: Render display using guacamole-common-js
|
||||||
|
RDM->>GuacLite: Send user input (keyboard/mouse via WebSocket)
|
||||||
|
GuacLite->>Guacd: Forward input
|
||||||
|
Guacd->>RemoteHost: Forward input (RDP)
|
||||||
|
|
||||||
|
Note over RDM, GuacLite: Communication uses Guacamole protocol over WebSocket
|
||||||
|
|
||||||
|
Note over MainAPI, GuacSvc: Token generation uses AES-256-CBC
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
* **分步测试:** 按照你的要求,每完成一个主要步骤(例如后端 API、前端连接逻辑)后都应进行测试。
|
||||||
|
* **密钥安全:** `GUACAMOLE_ENCRYPTION_KEY` 是敏感信息,应妥善保管,不要硬编码在代码中,建议使用环境变量或安全的配置管理方式。
|
||||||
|
* **错误处理:** 在前后端代码中添加健壮的错误处理逻辑。
|
||||||
|
* **依赖管理:** 确保所有必要的依赖都已正确安装。
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# RDP 连接重构计划
|
||||||
|
|
||||||
|
**目标:** 将 RDP 令牌生成流程移至后端,提高安全性,遵循高内聚低耦合原则。
|
||||||
|
|
||||||
|
**核心流程:**
|
||||||
|
前端请求主后端 -> 主后端获取连接信息和解密密码 -> 主后端调用 RDP 后端获取令牌 -> RDP 后端生成并返回令牌 -> 主后端将令牌返回给前端 -> 前端使用令牌连接 RDP 后端的 WebSocket。
|
||||||
|
|
||||||
|
**具体步骤:**
|
||||||
|
|
||||||
|
1. **主后端 (`packages/backend`)**
|
||||||
|
* **创建新 API 端点:**
|
||||||
|
* 在 `packages/backend/src/connections/connections.routes.ts` 中添加一个新的路由,例如 `POST /api/connections/:id/rdp-session`。
|
||||||
|
* 在 `packages/backend/src/connections/connections.controller.ts` 中添加对应的处理函数。
|
||||||
|
* **实现 Controller 逻辑:**
|
||||||
|
* 接收路径参数 `id`。
|
||||||
|
* 调用 `connection.service.ts` 的 `getConnectionWithDecryptedCredentials(id)`。
|
||||||
|
* 验证连接是否存在且类型为 'RDP'。
|
||||||
|
* 从环境变量或配置中读取 RDP 后端的 API 地址 (例如 `RDP_API_URL=http://localhost:9090`)。
|
||||||
|
* 构造向 RDP 后端 `/api/get-token` 发送请求所需的参数 (hostname, port, username, decryptedPassword, security, ignore-cert)。
|
||||||
|
* 使用 HTTP 客户端 (如 `axios`,如果未安装则需添加依赖) 向 `RDP_API_URL/api/get-token` 发送 GET 请求。
|
||||||
|
* 处理 RDP 后端的响应,提取 `token`。
|
||||||
|
* 将获取到的 `token` 返回给前端。
|
||||||
|
* 添加错误处理逻辑(连接不存在、类型错误、调用 RDP 后端失败等)。
|
||||||
|
* **配置:** 确保可以通过环境变量或其他方式配置 `RDP_API_URL`。
|
||||||
|
|
||||||
|
2. **RDP 后端 (`packages/rdp`)**
|
||||||
|
* **修改 CORS:**
|
||||||
|
* 在 `packages/rdp/backend/src/server.ts` 中调整 `cors` 中间件的配置。需要允许来自主后端 API 的请求源。例如,如果主后端运行在 `http://localhost:3000`,则配置为 `cors({ origin: ['http://localhost:5173', 'http://localhost:3000'] })` 或更灵活的配置。
|
||||||
|
* **(可选) 增强安全性:** 考虑在主后端调用 `/api/get-token` 时增加一个简单的认证机制(例如,一个共享的 API 密钥放在请求头中),并在 RDP 后端进行验证,防止未授权访问。
|
||||||
|
|
||||||
|
3. **前端 (`packages/frontend`)**
|
||||||
|
* **修改 RDP 连接触发逻辑:**
|
||||||
|
* 找到当前获取 RDP 令牌并发起连接的代码(可能在 `src/components/RemoteDesktopViewer.vue` 或相关 `composables` 中)。
|
||||||
|
* 修改代码,使其调用主后端新增的 `POST /api/connections/:id/rdp-session` 端点来获取 Guacamole 令牌。
|
||||||
|
* 使用从主后端获取到的 `token` 来初始化 `Guacamole.Client` 或类似实例。
|
||||||
|
* **确认 WebSocket 地址:** 确保 `Guacamole.Tunnel` 或 WebSocket 连接的目标地址仍然是 RDP 后端的 WebSocket 地址 (例如 `ws://localhost:8081` 或配置的值)。
|
||||||
|
|
||||||
|
**架构图 (Mermaid):**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant F as 前端 (packages/frontend)
|
||||||
|
participant BE as 主后端 (packages/backend)
|
||||||
|
participant RDP as RDP 后端 (packages/rdp)
|
||||||
|
participant GuacD as guacd
|
||||||
|
|
||||||
|
F->>+BE: POST /api/connections/{id}/rdp-session
|
||||||
|
BE->>+BE: getConnectionWithDecryptedCredentials(id)
|
||||||
|
Note right of BE: 获取连接信息 (host, port, user) 和解密后的密码
|
||||||
|
BE-->>-BE: 返回连接信息和密码
|
||||||
|
alt 连接类型为 RDP
|
||||||
|
BE->>+RDP: GET /api/get-token?hostname=...&port=...&username=...&password=...
|
||||||
|
Note right of BE: 从配置读取 RDP_API_URL
|
||||||
|
RDP->>+RDP: 构建 Guacamole 连接参数
|
||||||
|
RDP->>+RDP: encryptToken(connectionParams)
|
||||||
|
RDP-->>-RDP: 返回加密后的令牌 (token)
|
||||||
|
RDP-->>-BE: 返回 { token: "..." }
|
||||||
|
BE-->>-F: 返回 { token: "..." }
|
||||||
|
else 连接类型非 RDP
|
||||||
|
BE-->>-F: 返回错误信息
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over F: 前端获取到 token
|
||||||
|
F->>+RDP: WebSocket 连接 (ws://<RDP_WS_HOST>:<RDP_WS_PORT>)
|
||||||
|
Note right of F: 使用获取到的 token 进行认证
|
||||||
|
RDP->>+GuacD: 建立 Guacamole 连接
|
||||||
|
GuacD-->>-RDP: Guacamole 协议通信
|
||||||
|
RDP-->>-F: Guacamole 协议通信 (通过 WebSocket)
|
||||||
Generated
+14
-7
@@ -16,7 +16,8 @@
|
|||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
"plist": "^3.1.0",
|
"plist": "^3.1.0",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0",
|
||||||
|
"ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.4",
|
"@tailwindcss/postcss": "^4.1.4",
|
||||||
@@ -2036,9 +2037,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.8.4",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
|
||||||
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
|
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
@@ -3641,6 +3642,12 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/guacamole-common-js": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg==",
|
||||||
|
"license": "Apache 2.0"
|
||||||
|
},
|
||||||
"node_modules/has-symbols": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
@@ -7415,7 +7422,7 @@
|
|||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/session-file-store": "^1.2.5",
|
"@types/session-file-store": "^1.2.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.9.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
@@ -7434,7 +7441,6 @@
|
|||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"ws": "^8.18.1",
|
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -7456,7 +7462,7 @@
|
|||||||
},
|
},
|
||||||
"packages/frontend": {
|
"packages/frontend": {
|
||||||
"name": "@nexus-terminal/frontend",
|
"name": "@nexus-terminal/frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
@@ -7464,6 +7470,7 @@
|
|||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-search": "^0.15.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"guacamole-common-js": "^1.5.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
|
|||||||
+2
-1
@@ -29,7 +29,8 @@
|
|||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
"plist": "^3.1.0",
|
"plist": "^3.1.0",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0",
|
||||||
|
"ws": "^8.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.4",
|
"@tailwindcss/postcss": "^4.1.4",
|
||||||
|
|||||||
Binary file not shown.
@@ -12,7 +12,7 @@
|
|||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/session-file-store": "^1.2.5",
|
"@types/session-file-store": "^1.2.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.9.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
@@ -31,7 +31,6 @@
|
|||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"ws": "^8.18.1",
|
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -257,3 +257,102 @@ export const importConnections = async (req: Request, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
import axios from 'axios'; // +++ Import axios +++
|
||||||
|
|
||||||
|
// TODO: Make RDP backend URL configurable
|
||||||
|
const RDP_BACKEND_API_BASE = process.env.RDP_BACKEND_API_BASE || 'http://localhost:9090';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 RDP 会话的 Guacamole 令牌 (通过调用 RDP 后端)
|
||||||
|
* GET /api/v1/connections/:id/rdp-session
|
||||||
|
*/
|
||||||
|
export const getRdpSessionToken = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const connectionId = parseInt(req.params.id, 10);
|
||||||
|
if (isNaN(connectionId)) {
|
||||||
|
res.status(400).json({ message: '无效的连接 ID。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 获取连接信息和解密后的凭证
|
||||||
|
const connectionData = await ConnectionService.getConnectionWithDecryptedCredentials(connectionId);
|
||||||
|
|
||||||
|
if (!connectionData) {
|
||||||
|
res.status(404).json({ message: '连接未找到。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connection, decryptedPassword } = connectionData;
|
||||||
|
|
||||||
|
// 2. 验证连接类型是否为 RDP
|
||||||
|
if (connection.type !== 'RDP') {
|
||||||
|
res.status(400).json({ message: '此连接类型不是 RDP。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 验证 RDP 连接是否使用密码认证
|
||||||
|
if (connection.auth_method !== 'password' || !decryptedPassword) {
|
||||||
|
console.warn(`[Controller:getRdpSessionToken] RDP connection ${connectionId} does not use password auth or password decryption failed.`);
|
||||||
|
res.status(400).json({ message: 'RDP 连接需要使用密码认证,或密码解密失败。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 准备调用 RDP 后端的参数
|
||||||
|
const rdpApiParams = new URLSearchParams({
|
||||||
|
hostname: connection.host,
|
||||||
|
port: connection.port.toString(),
|
||||||
|
username: connection.username,
|
||||||
|
password: decryptedPassword, // 使用解密后的密码
|
||||||
|
// Add other RDP parameters from connection object if needed by rdp backend
|
||||||
|
security: (connection as any).rdp_security || 'any',
|
||||||
|
ignoreCert: String((connection as any).rdp_ignore_cert ?? true),
|
||||||
|
});
|
||||||
|
const rdpTokenUrl = `${RDP_BACKEND_API_BASE}/api/get-token?${rdpApiParams.toString()}`;
|
||||||
|
|
||||||
|
console.log(`[Controller:getRdpSessionToken] Calling RDP backend API: ${RDP_BACKEND_API_BASE}/api/get-token?...`);
|
||||||
|
|
||||||
|
// 5. 调用 RDP 后端 API 获取 Guacamole 令牌
|
||||||
|
const rdpResponse = await axios.get<{ token: string }>(rdpTokenUrl, {
|
||||||
|
timeout: 10000 // 设置 10 秒超时
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rdpResponse.status !== 200 || !rdpResponse.data?.token) {
|
||||||
|
console.error(`[Controller:getRdpSessionToken] RDP backend API call failed or returned invalid data. Status: ${rdpResponse.status}`, rdpResponse.data);
|
||||||
|
throw new Error('从 RDP 后端获取令牌失败。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guacamoleToken = rdpResponse.data.token;
|
||||||
|
console.log(`[Controller:getRdpSessionToken] Received Guacamole token from RDP backend for connection ${connectionId}`);
|
||||||
|
|
||||||
|
// 6. 将 Guacamole 令牌返回给前端
|
||||||
|
res.status(200).json({ token: guacamoleToken });
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error);
|
||||||
|
|
||||||
|
let statusCode = 500;
|
||||||
|
let message = '获取 RDP 会话令牌时发生内部服务器错误。';
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
message = '调用 RDP 后端服务时出错。';
|
||||||
|
if (error.response) {
|
||||||
|
// RDP 后端返回了错误响应
|
||||||
|
console.error('[Controller:getRdpSessionToken] RDP backend error response:', error.response.data);
|
||||||
|
message += ` (状态: ${error.response.status})`;
|
||||||
|
statusCode = error.response.status >= 500 ? 502 : 400; // Bad Gateway or Bad Request
|
||||||
|
} else if (error.request) {
|
||||||
|
// 请求已发出但没有收到响应 (网络问题、超时)
|
||||||
|
console.error('[Controller:getRdpSessionToken] No response from RDP backend.');
|
||||||
|
message += ' (无法连接或超时)';
|
||||||
|
statusCode = 504; // Gateway Timeout
|
||||||
|
} else {
|
||||||
|
// 设置请求时发生错误
|
||||||
|
console.error('[Controller:getRdpSessionToken] Axios request setup error:', error.message);
|
||||||
|
}
|
||||||
|
} else if (error.message.includes('解密失败')) {
|
||||||
|
message = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({ message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
testConnection,
|
testConnection,
|
||||||
testUnsavedConnection,
|
testUnsavedConnection,
|
||||||
exportConnections,
|
exportConnections,
|
||||||
importConnections
|
importConnections, // <-- Add comma here
|
||||||
|
getRdpSessionToken // +++ Import getRdpSessionToken +++
|
||||||
} from './connections.controller';
|
} from './connections.controller';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -76,4 +77,6 @@ router.post('/:id/test', testConnection);
|
|||||||
// POST /api/v1/connections/test-unsaved - 测试未保存的连接信息
|
// POST /api/v1/connections/test-unsaved - 测试未保存的连接信息
|
||||||
router.post('/test-unsaved', testUnsavedConnection);
|
router.post('/test-unsaved', testUnsavedConnection);
|
||||||
|
|
||||||
|
// Removed GET /:id/rdp-token route
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -56,7 +56,24 @@ const initializeEnvironment = async () => {
|
|||||||
keysGenerated = true;
|
keysGenerated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 如果生成了新密钥,则追加到 .env 文件
|
// 4. 检查 GUACD_HOST 和 GUACD_PORT
|
||||||
|
if (!process.env.GUACD_HOST) {
|
||||||
|
console.warn('[ENV Init] GUACD_HOST 未设置,将使用默认值 "localhost"');
|
||||||
|
process.env.GUACD_HOST = 'localhost';
|
||||||
|
// Optionally add to keysToAppend if you want to save the default
|
||||||
|
// keysToAppend += `\nGUACD_HOST=localhost`;
|
||||||
|
// keysGenerated = true; // Mark if you want to save
|
||||||
|
}
|
||||||
|
if (!process.env.GUACD_PORT) {
|
||||||
|
console.warn('[ENV Init] GUACD_PORT 未设置,将使用默认值 "4822"');
|
||||||
|
process.env.GUACD_PORT = '4822';
|
||||||
|
// Optionally add to keysToAppend
|
||||||
|
// keysToAppend += `\nGUACD_PORT=4822`;
|
||||||
|
// keysGenerated = true; // Mark if you want to save
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 5. 如果生成了新密钥或添加了默认值,则追加到 .env 文件
|
||||||
if (keysGenerated) {
|
if (keysGenerated) {
|
||||||
try {
|
try {
|
||||||
// 确保追加前有换行符 (如果文件非空)
|
// 确保追加前有换行符 (如果文件非空)
|
||||||
@@ -88,6 +105,20 @@ const initializeEnvironment = async () => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. 最终检查 (包括 Guacamole 相关)
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (!process.env.ENCRYPTION_KEY) {
|
||||||
|
console.error('错误:生产环境中 ENCRYPTION_KEY 最终未能设置!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!process.env.SESSION_SECRET) {
|
||||||
|
console.error('错误:生产环境中 SESSION_SECRET 最终未能设置!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// Guacd host/port are less critical to halt on, defaults might work
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
// --- 结束环境变量和密钥初始化 ---
|
// --- 结束环境变量和密钥初始化 ---
|
||||||
|
|
||||||
@@ -193,7 +224,8 @@ const startServer = () => {
|
|||||||
|
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
console.log(`后端服务器正在监听 http://localhost:${port}`);
|
console.log(`后端服务器正在监听 http://localhost:${port}`);
|
||||||
initializeWebSocket(server, sessionMiddleware as RequestHandler);
|
initializeWebSocket(server, sessionMiddleware as RequestHandler); // Initialize existing WebSocket
|
||||||
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -271,5 +271,76 @@ export const deleteConnection = async (id: number): Promise<boolean> => {
|
|||||||
return deleted;
|
return deleted;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接信息(包含标签)以及解密后的凭证(如果适用)
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @returns 包含 ConnectionWithTags 和解密后密码/密钥的对象,或 null
|
||||||
|
*/
|
||||||
|
export const getConnectionWithDecryptedCredentials = async (
|
||||||
|
id: number
|
||||||
|
): Promise<{ connection: ConnectionWithTags; decryptedPassword?: string; decryptedPrivateKey?: string; decryptedPassphrase?: string } | null> => {
|
||||||
|
// 1. 获取完整的连接数据(包含加密字段)
|
||||||
|
// Assuming findFullConnectionById exists and returns FullConnectionDbRow or null
|
||||||
|
const fullConnectionDbRow = await ConnectionRepository.findFullConnectionById(id);
|
||||||
|
if (!fullConnectionDbRow) {
|
||||||
|
console.log(`[Service:getConnWithDecrypt] Connection not found for ID: ${id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Convert DbRow to the stricter FullConnectionData type expected by the service/types file
|
||||||
|
// Handle potential undefined by defaulting to null
|
||||||
|
const fullConnection: FullConnectionData = {
|
||||||
|
...fullConnectionDbRow,
|
||||||
|
encrypted_password: fullConnectionDbRow.encrypted_password ?? null,
|
||||||
|
encrypted_private_key: fullConnectionDbRow.encrypted_private_key ?? null,
|
||||||
|
encrypted_passphrase: fullConnectionDbRow.encrypted_passphrase ?? null,
|
||||||
|
// Ensure other fields match FullConnectionData if necessary
|
||||||
|
// (Assuming FullConnectionDbRow includes all fields of FullConnectionData)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 获取带标签的连接数据(用于返回给调用者)
|
||||||
|
const connectionWithTags: ConnectionWithTags | null = await ConnectionRepository.findConnectionByIdWithTags(id);
|
||||||
|
if (!connectionWithTags) {
|
||||||
|
// This shouldn't happen if findFullConnectionById succeeded, but good practice to check
|
||||||
|
console.error(`[Service:getConnWithDecrypt] Mismatch: Full connection found but tagged connection not found for ID: ${id}`);
|
||||||
|
// Consider throwing an error or returning a specific error state
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 解密凭证
|
||||||
|
let decryptedPassword: string | undefined = undefined;
|
||||||
|
let decryptedPrivateKey: string | undefined = undefined;
|
||||||
|
let decryptedPassphrase: string | undefined = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decrypt password if method is 'password' and encrypted password exists
|
||||||
|
if (fullConnection.auth_method === 'password' && fullConnection.encrypted_password) {
|
||||||
|
decryptedPassword = decrypt(fullConnection.encrypted_password);
|
||||||
|
}
|
||||||
|
// Decrypt key and passphrase if method is 'key'
|
||||||
|
else if (fullConnection.auth_method === 'key') {
|
||||||
|
if (fullConnection.encrypted_private_key) {
|
||||||
|
decryptedPrivateKey = decrypt(fullConnection.encrypted_private_key);
|
||||||
|
}
|
||||||
|
// Only decrypt passphrase if it exists
|
||||||
|
if (fullConnection.encrypted_passphrase) {
|
||||||
|
decryptedPassphrase = decrypt(fullConnection.encrypted_passphrase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Service:getConnWithDecrypt] Failed to decrypt credentials for connection ID ${id}:`, error);
|
||||||
|
// Decide how to handle decryption errors. Throw? Return null password?
|
||||||
|
// For now, we'll log and continue, returning undefined credentials.
|
||||||
|
// Consider throwing an error if credentials are required but decryption fails.
|
||||||
|
// Or return a specific error structure: return { error: 'Decryption failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Service:getConnWithDecrypt] Returning data for ID: ${id}, Auth Method: ${fullConnection.auth_method}`);
|
||||||
|
return {
|
||||||
|
connection: connectionWithTags,
|
||||||
|
decryptedPassword,
|
||||||
|
decryptedPrivateKey,
|
||||||
|
decryptedPassphrase,
|
||||||
|
};
|
||||||
|
};
|
||||||
// 注意:testConnection、importConnections、exportConnections 逻辑
|
// 注意:testConnection、importConnections、exportConnections 逻辑
|
||||||
// 将分别移至 SshService 和 ImportExportService。
|
// 将分别移至 SshService 和 ImportExportService。
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
// This file is intentionally left blank as Guacamole logic is handled by the separate rdp package.
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-search": "^0.15.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"guacamole-common-js": "^1.5.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
|
|||||||
@@ -1,61 +1,377 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
// @ts-ignore - guacamole-common-js lacks official types
|
||||||
|
import Guacamole from 'guacamole-common-js';
|
||||||
|
import apiClient from '../utils/apiClient'; // 假设 API 客户端路径
|
||||||
|
import { ConnectionInfo } from '../stores/connections.store'; // 假设 ConnectionInfo 类型路径
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Props (可以稍后添加,例如接收连接信息)
|
// --- Props ---
|
||||||
// const props = defineProps<{
|
const props = defineProps<{
|
||||||
// connection?: ConnectionInfo; // 假设有 ConnectionInfo 类型
|
connection: ConnectionInfo | null; // 接收连接信息
|
||||||
// }>();
|
}>();
|
||||||
|
|
||||||
// Emits (用于通知父组件关闭模态框)
|
// --- Emits ---
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
// --- State Refs ---
|
||||||
|
const rdpDisplayRef = ref<HTMLDivElement | null>(null); // Guacamole 显示容器
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const guacClient = ref<any | null>(null); // Guacamole 客户端实例 (使用 any 因为类型缺失)
|
||||||
|
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
|
||||||
|
const statusMessage = ref('');
|
||||||
|
const keyboard = ref<any | null>(null); // Guacamole Keyboard instance
|
||||||
|
const mouse = ref<any | null>(null); // Guacamole Mouse instance
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
// Configuration for the separate RDP backend service
|
||||||
|
// TODO: Make these configurable
|
||||||
|
const RDP_BACKEND_API_BASE = 'http://localhost:9090'; // Default port for test-rdp/packages/rdp API
|
||||||
|
const RDP_BACKEND_WEBSOCKET_URL = 'ws://localhost:8081'; // Default port for test-rdp/packages/rdp WebSocket
|
||||||
|
|
||||||
|
// --- Connection Logic ---
|
||||||
|
const connectRdp = async () => {
|
||||||
|
if (!props.connection || !rdpDisplayRef.value) {
|
||||||
|
statusMessage.value = t('remoteDesktopModal.errors.missingInfo');
|
||||||
|
connectionStatus.value = 'error';
|
||||||
|
console.error('[RDP Modal] Connection info or display element missing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理之前的显示内容
|
||||||
|
while (rdpDisplayRef.value.firstChild) {
|
||||||
|
rdpDisplayRef.value.removeChild(rdpDisplayRef.value.firstChild);
|
||||||
|
}
|
||||||
|
disconnectRdp(); // Ensure any previous connection is cleaned up
|
||||||
|
|
||||||
|
connectionStatus.value = 'connecting';
|
||||||
|
statusMessage.value = t('remoteDesktopModal.status.fetchingToken');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 从独立的 RDP 后端获取 Token
|
||||||
|
// WARNING: Sending credentials directly like this is insecure if the API is not properly secured (e.g., HTTPS, network isolation).
|
||||||
|
// WARNING: props.connection likely does NOT contain the password. Using a placeholder.
|
||||||
|
// You MUST implement a secure way to get the password here.
|
||||||
|
const connectionParams = new URLSearchParams({
|
||||||
|
hostname: props.connection.host,
|
||||||
|
port: props.connection.port.toString(),
|
||||||
|
username: props.connection.username,
|
||||||
|
// !!! SECURITY RISK: Password should not be handled like this !!!
|
||||||
|
// Replace this with a secure method (e.g., prompt user, fetch securely)
|
||||||
|
password: (props.connection as any).password || 'PASSWORD_PLACEHOLDER', // Assuming password might exist, otherwise use placeholder
|
||||||
|
security: (props.connection as any).rdp_security || 'any', // Use RDP specific fields if available
|
||||||
|
ignoreCert: String((props.connection as any).rdp_ignore_cert ?? true),
|
||||||
|
// Add other necessary params supported by the rdp backend API
|
||||||
|
});
|
||||||
|
const apiUrl = `${RDP_BACKEND_API_BASE}/api/get-token?${connectionParams.toString()}`;
|
||||||
|
console.log(`[RDP Modal] Fetching token from RDP backend: ${RDP_BACKEND_API_BASE}/api/get-token?...`);
|
||||||
|
|
||||||
|
// Use fetch directly as apiClient might be configured for the main backend
|
||||||
|
const response = await fetch(apiUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Failed to parse error response' }));
|
||||||
|
throw new Error(`RDP API Error (${response.status}): ${errorData.error || response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const token = data.token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Token not found in RDP API response');
|
||||||
|
}
|
||||||
|
console.log('[RDP Modal] Received token.');
|
||||||
|
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
|
||||||
|
|
||||||
|
// 2. 连接 WebSocket (to RDP backend's WebSocket server)
|
||||||
|
const tunnelUrl = `${RDP_BACKEND_WEBSOCKET_URL}/?token=${encodeURIComponent(token)}`;
|
||||||
|
console.log(`[RDP Modal] Connecting WebSocket to: ${RDP_BACKEND_WEBSOCKET_URL}/?token=...`);
|
||||||
|
// @ts-ignore
|
||||||
|
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
||||||
|
|
||||||
|
tunnel.onerror = (status: any) => {
|
||||||
|
console.error("[RDP Modal Tunnel] Tunnel Error Status:", status);
|
||||||
|
const errorMessage = status.message || 'Unknown tunnel error';
|
||||||
|
const errorCode = status.code || 'N/A';
|
||||||
|
statusMessage.value = `${t('remoteDesktopModal.errors.tunnelError')} (${errorCode}): ${errorMessage}`;
|
||||||
|
connectionStatus.value = 'error';
|
||||||
|
disconnectRdp(); // Clean up on tunnel error
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 创建 Guacamole 客户端
|
||||||
|
// @ts-ignore
|
||||||
|
guacClient.value = new Guacamole.Client(tunnel);
|
||||||
|
|
||||||
|
// 4. 添加显示元素到 DOM
|
||||||
|
rdpDisplayRef.value.appendChild(guacClient.value.getDisplay().getElement());
|
||||||
|
|
||||||
|
// 5. 处理客户端状态变化
|
||||||
|
guacClient.value.onstatechange = (state: number) => {
|
||||||
|
console.log("[RDP Modal] Guacamole client state changed:", state);
|
||||||
|
switch (state) {
|
||||||
|
case 0: // IDLE
|
||||||
|
statusMessage.value = t('remoteDesktopModal.status.idle');
|
||||||
|
connectionStatus.value = 'disconnected';
|
||||||
|
break;
|
||||||
|
case 1: // CONNECTING
|
||||||
|
statusMessage.value = t('remoteDesktopModal.status.connectingRdp');
|
||||||
|
connectionStatus.value = 'connecting';
|
||||||
|
break;
|
||||||
|
case 2: // WAITING
|
||||||
|
statusMessage.value = t('remoteDesktopModal.status.waiting');
|
||||||
|
connectionStatus.value = 'connecting';
|
||||||
|
break;
|
||||||
|
case 3: // CONNECTED
|
||||||
|
statusMessage.value = t('remoteDesktopModal.status.connected');
|
||||||
|
connectionStatus.value = 'connected';
|
||||||
|
setupInputListeners(); // 连接成功后设置输入监听
|
||||||
|
break;
|
||||||
|
case 4: // DISCONNECTING
|
||||||
|
statusMessage.value = t('remoteDesktopModal.status.disconnecting');
|
||||||
|
connectionStatus.value = 'disconnected';
|
||||||
|
break;
|
||||||
|
case 5: // DISCONNECTED
|
||||||
|
statusMessage.value = t('remoteDesktopModal.status.disconnected');
|
||||||
|
connectionStatus.value = 'disconnected';
|
||||||
|
// disconnectRdp(); // State change might already trigger cleanup, avoid double disconnect
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusMessage.value = `${t('remoteDesktopModal.status.unknownState')}: ${state}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. 处理客户端错误
|
||||||
|
guacClient.value.onerror = (status: any) => {
|
||||||
|
console.error("[RDP Modal Client] Client Error Status:", status);
|
||||||
|
const errorMessage = status.message || 'Unknown client error';
|
||||||
|
statusMessage.value = `${t('remoteDesktopModal.errors.clientError')}: ${errorMessage}`;
|
||||||
|
connectionStatus.value = 'error';
|
||||||
|
disconnectRdp(); // Clean up on client error
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7. (可选) 处理指令日志
|
||||||
|
// guacClient.value.oninstruction = (opcode: string, args: any[]) => {
|
||||||
|
// if (['sync', 'size', 'name', 'error', 'disconnect'].includes(opcode)) {
|
||||||
|
// console.log(`[RDP Modal Client] Received instruction: ${opcode}`, args);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// 8. 开始连接
|
||||||
|
console.log("[RDP Modal] Initiating Guacamole client connection...");
|
||||||
|
guacClient.value.connect();
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[RDP Modal] Connection failed:", error);
|
||||||
|
statusMessage.value = `${t('remoteDesktopModal.errors.connectionFailed')}: ${error.response?.data?.message || error.message || String(error)}`;
|
||||||
|
connectionStatus.value = 'error';
|
||||||
|
disconnectRdp(); // Clean up on failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Input Handling ---
|
||||||
|
const setupInputListeners = () => {
|
||||||
|
if (!guacClient.value || !rdpDisplayRef.value) return;
|
||||||
|
console.log("[RDP Modal Input] Setting up input listeners...");
|
||||||
|
try {
|
||||||
|
const displayEl = guacClient.value.getDisplay().getElement() as HTMLElement;
|
||||||
|
|
||||||
|
// --- Mouse ---
|
||||||
|
// @ts-ignore
|
||||||
|
mouse.value = new Guacamole.Mouse(displayEl);
|
||||||
|
// @ts-ignore
|
||||||
|
mouse.value.onmousedown = mouse.value.onmouseup = mouse.value.onmousemove = (mouseState: any) => {
|
||||||
|
if (guacClient.value) {
|
||||||
|
guacClient.value.sendMouseState(mouseState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
console.log("[RDP Modal Input] Mouse listeners attached.");
|
||||||
|
|
||||||
|
// --- Keyboard ---
|
||||||
|
// @ts-ignore
|
||||||
|
keyboard.value = new Guacamole.Keyboard(document); // Listen on document for global key events
|
||||||
|
|
||||||
|
// Prevent default browser actions for keys handled by Guacamole
|
||||||
|
// keyboard.value.listenTo(document); // This might interfere with other inputs, attach carefully
|
||||||
|
|
||||||
|
keyboard.value.onkeydown = (keysym: number) => {
|
||||||
|
if (guacClient.value) {
|
||||||
|
// console.log("[RDP Input] KeyDown:", keysym);
|
||||||
|
guacClient.value.sendKeyEvent(1, keysym);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
keyboard.value.onkeyup = (keysym: number) => {
|
||||||
|
if (guacClient.value) {
|
||||||
|
// console.log("[RDP Input] KeyUp:", keysym);
|
||||||
|
guacClient.value.sendKeyEvent(0, keysym);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
console.log("[RDP Modal Input] Keyboard listeners attached.");
|
||||||
|
|
||||||
|
} catch (inputError) {
|
||||||
|
console.error("[RDP Modal Input] Error setting up input listeners:", inputError);
|
||||||
|
statusMessage.value = t('remoteDesktopModal.errors.inputError');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeInputListeners = () => {
|
||||||
|
console.log("[RDP Modal Input] Removing input listeners...");
|
||||||
|
if (keyboard.value) {
|
||||||
|
// If listenTo(document) was used, need a way to remove it,
|
||||||
|
// otherwise just nullifying the handlers might be enough.
|
||||||
|
// Guacamole.Keyboard doesn't have an obvious 'stopListening'
|
||||||
|
keyboard.value.onkeydown = null;
|
||||||
|
keyboard.value.onkeyup = null;
|
||||||
|
keyboard.value = null; // Release reference
|
||||||
|
console.log("[RDP Modal Input] Keyboard listeners removed.");
|
||||||
|
}
|
||||||
|
if (mouse.value) {
|
||||||
|
// Mouse listeners are attached to the display element,
|
||||||
|
// removing the element itself or nullifying handlers should work.
|
||||||
|
mouse.value.onmousedown = null;
|
||||||
|
mouse.value.onmouseup = null;
|
||||||
|
mouse.value.onmousemove = null;
|
||||||
|
mouse.value = null; // Release reference
|
||||||
|
console.log("[RDP Modal Input] Mouse listeners removed.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- Disconnect Logic ---
|
||||||
|
const disconnectRdp = () => {
|
||||||
|
removeInputListeners(); // Remove listeners first
|
||||||
|
if (guacClient.value) {
|
||||||
|
console.log("[RDP Modal] Disconnecting Guacamole client.");
|
||||||
|
guacClient.value.disconnect();
|
||||||
|
guacClient.value = null;
|
||||||
|
}
|
||||||
|
// Clean up display manually if needed
|
||||||
|
if (rdpDisplayRef.value) {
|
||||||
|
while (rdpDisplayRef.value.firstChild) {
|
||||||
|
rdpDisplayRef.value.removeChild(rdpDisplayRef.value.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (connectionStatus.value !== 'error') { // Don't overwrite error messages
|
||||||
|
connectionStatus.value = 'disconnected';
|
||||||
|
statusMessage.value = t('remoteDesktopModal.status.disconnected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Modal Close Handler ---
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
|
disconnectRdp(); // Ensure disconnection when modal is closed
|
||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Lifecycle Hooks ---
|
||||||
|
onMounted(() => {
|
||||||
|
// Automatically connect when component mounts if connection is provided
|
||||||
|
if (props.connection) {
|
||||||
|
// Use nextTick to ensure the display ref is available
|
||||||
|
nextTick(() => {
|
||||||
|
connectRdp();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
|
||||||
|
connectionStatus.value = 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Ensure disconnection on component unmount
|
||||||
|
disconnectRdp();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for connection prop changes (e.g., if the modal is reused)
|
||||||
|
watch(() => props.connection, (newConnection, oldConnection) => {
|
||||||
|
if (newConnection && newConnection.id !== oldConnection?.id) {
|
||||||
|
console.log('[RDP Modal] Connection prop changed, reconnecting...');
|
||||||
|
// Use nextTick to ensure the display ref is available after potential v-if changes
|
||||||
|
nextTick(() => {
|
||||||
|
connectRdp(); // Connect with the new connection info
|
||||||
|
});
|
||||||
|
} else if (!newConnection) {
|
||||||
|
disconnectRdp(); // Disconnect if connection becomes null
|
||||||
|
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
|
||||||
|
connectionStatus.value = 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-overlay p-4"> <!-- Changed background class -->
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-overlay p-4 backdrop-blur-sm">
|
||||||
<div class="bg-background text-foreground rounded-lg shadow-xl w-11/12 max-w-4xl h-5/6 flex flex-col overflow-hidden border border-border">
|
<div class="bg-background text-foreground rounded-lg shadow-xl w-11/12 max-w-6xl h-[90%] flex flex-col overflow-hidden border border-border"> <!-- Increased max-width and height -->
|
||||||
<!-- Modal Header -->
|
<!-- Modal Header -->
|
||||||
<div class="flex items-center justify-between p-4 border-b border-border flex-shrink-0">
|
<div class="flex items-center justify-between p-3 border-b border-border flex-shrink-0"> <!-- Reduced padding -->
|
||||||
<h3 class="text-lg font-semibold">
|
<h3 class="text-base font-semibold truncate"> <!-- Reduced text size, added truncate -->
|
||||||
<!-- 可以根据 props.connection?.name 动态显示标题 -->
|
<i class="fas fa-desktop mr-2 text-text-secondary"></i>
|
||||||
{{ t('remoteDesktopModal.titlePlaceholder') }}
|
{{ t('remoteDesktopModal.title') }} - {{ props.connection?.name || props.connection?.host || t('remoteDesktopModal.titlePlaceholder') }}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<div class="flex items-center space-x-2">
|
||||||
@click="closeModal"
|
<!-- Status Indicator -->
|
||||||
class="text-text-secondary hover:text-foreground transition-colors duration-150"
|
<span class="text-xs px-2 py-0.5 rounded"
|
||||||
:title="t('common.close')"
|
:class="{
|
||||||
>
|
'bg-yellow-200 text-yellow-800': connectionStatus === 'connecting',
|
||||||
<i class="fas fa-times fa-lg"></i>
|
'bg-green-200 text-green-800': connectionStatus === 'connected',
|
||||||
</button>
|
'bg-red-200 text-red-800': connectionStatus === 'error',
|
||||||
</div>
|
'bg-gray-200 text-gray-800': connectionStatus === 'disconnected'
|
||||||
|
}">
|
||||||
<!-- Modal Body (Placeholder) -->
|
{{ connectionStatus }}
|
||||||
<div class="flex-grow p-4 overflow-y-auto">
|
</span>
|
||||||
<div class="flex items-center justify-center h-full text-text-secondary text-center">
|
<button
|
||||||
<div>
|
@click="closeModal"
|
||||||
<i class="fas fa-desktop fa-3x mb-4"></i>
|
class="text-text-secondary hover:text-foreground transition-colors duration-150 p-1 rounded hover:bg-hover"
|
||||||
<p>{{ t('remoteDesktopModal.contentPlaceholder') }}</p>
|
:title="t('common.close')"
|
||||||
<!-- 这里将来会是 Guacamole 或其他 RDP 客户端的容器 -->
|
>
|
||||||
</div>
|
<i class="fas fa-times fa-lg"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Footer (Optional) -->
|
<!-- Modal Body (Guacamole Display Area) -->
|
||||||
<!-- <div class="p-4 border-t border-border flex-shrink-0 text-right">
|
<div class="flex-grow relative bg-black"> <!-- Added relative and bg-black -->
|
||||||
<button @click="closeModal" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-dark">
|
<div ref="rdpDisplayRef" class="rdp-display-container w-full h-full">
|
||||||
{{ t('common.close') }}
|
<!-- Guacamole display will be rendered here -->
|
||||||
</button>
|
</div>
|
||||||
</div> -->
|
<!-- Loading/Error Overlay -->
|
||||||
|
<div v-if="connectionStatus === 'connecting' || connectionStatus === 'error'"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-75 text-white p-4 z-10">
|
||||||
|
<div class="text-center">
|
||||||
|
<i v-if="connectionStatus === 'connecting'" class="fas fa-spinner fa-spin fa-2x mb-3"></i>
|
||||||
|
<i v-else class="fas fa-exclamation-triangle fa-2x mb-3 text-red-400"></i>
|
||||||
|
<p class="text-sm">{{ statusMessage }}</p>
|
||||||
|
<button v-if="connectionStatus === 'error'"
|
||||||
|
@click="connectRdp"
|
||||||
|
class="mt-4 px-3 py-1 bg-primary text-white rounded text-xs hover:bg-primary-dark">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Footer (Status Bar) -->
|
||||||
|
<div class="p-2 border-t border-border flex-shrink-0 text-xs text-text-secondary bg-header"> <!-- Reduced padding -->
|
||||||
|
{{ statusMessage }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 如果需要,可以在这里添加特定的样式 */
|
.rdp-display-container {
|
||||||
|
/* Ensure the container itself doesn't introduce scrollbars unnecessarily */
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative; /* Needed for Guacamole's absolute positioning */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Guacamole injects its own elements, target them carefully if needed */
|
||||||
|
.rdp-display-container :deep(div) {
|
||||||
|
/* Guacamole layers might need relative positioning */
|
||||||
|
/* position: relative !important; */ /* Avoid !important if possible */
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-display-container :deep(canvas) {
|
||||||
|
/* Ensure canvas scales correctly if needed, Guacamole usually handles this */
|
||||||
|
/* width: 100%; */
|
||||||
|
/* height: 100%; */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -751,6 +751,30 @@
|
|||||||
"searchPlaceholder": "搜索名称或主机...",
|
"searchPlaceholder": "搜索名称或主机...",
|
||||||
"noResults": "未找到匹配 \"{searchTerm}\" 的连接。"
|
"noResults": "未找到匹配 \"{searchTerm}\" 的连接。"
|
||||||
},
|
},
|
||||||
|
"remoteDesktopModal": {
|
||||||
|
"title": "远程桌面",
|
||||||
|
"titlePlaceholder": "远程桌面连接",
|
||||||
|
"status": {
|
||||||
|
"fetchingToken": "正在获取连接令牌...",
|
||||||
|
"connectingWs": "正在连接 WebSocket...",
|
||||||
|
"idle": "空闲",
|
||||||
|
"connectingRdp": "正在连接远程桌面...",
|
||||||
|
"waiting": "等待服务器响应...",
|
||||||
|
"connected": "已连接",
|
||||||
|
"disconnecting": "正在断开连接...",
|
||||||
|
"disconnected": "已断开连接",
|
||||||
|
"unknownState": "未知状态"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"missingInfo": "连接信息或显示元素丢失。",
|
||||||
|
"tunnelError": "通道错误",
|
||||||
|
"clientError": "客户端错误",
|
||||||
|
"connectionFailed": "连接失败",
|
||||||
|
"inputError": "设置输入监听器时出错。",
|
||||||
|
"noConnection": "未提供连接信息。",
|
||||||
|
"tokenError": "获取令牌失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
"commandInputBar": {
|
"commandInputBar": {
|
||||||
"placeholder": "在此输入命令后按 Enter 发送到终端...",
|
"placeholder": "在此输入命令后按 Enter 发送到终端...",
|
||||||
"searchPlaceholder": "在终端中搜索...",
|
"searchPlaceholder": "在终端中搜索...",
|
||||||
|
|||||||
Reference in New Issue
Block a user