diff --git a/.env b/.env index 9b757ea..e70809c 100644 --- a/.env +++ b/.env @@ -1,6 +1,8 @@ # local/docker -DEPLOYMENT_MODE=docker +DEPLOYMENT_MODE=local -RDP_SERVICE_URL_DOCKER=ws://rdp:8081 - -RDP_SERVICE_URL_LOCAL=ws://localhost:8081 +# Backend API Base URLs +REMOTE_GATEWAY_API_BASE_LOCAL=http://localhost:9090 +REMOTE_GATEWAY_API_BASE_DOCKER=http://remote-gateway:9090 +REMOTE_GATEWAY_WS_URL_LOCAL=ws://localhost:8080 +REMOTE_GATEWAY_WS_URL_DOCKER=ws://remote-gateway:8080 diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 62bfcb5..2a0767a 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -10,6 +10,7 @@ services: depends_on: - backend - rdp + - vnc networks: - nexus-terminal-network @@ -27,6 +28,7 @@ services: NODE_ENV: production PORT: 3001 RDP_BACKEND_API_BASE: http://rdp:9090 + VNC_BACKEND_API_BASE: http://vnc:9091 volumes: - ./data:/app/data networks: @@ -52,6 +54,30 @@ services: - guacd - backend + vnc: + build: + context: . + dockerfile: packages/vnc/Dockerfile + image: nexus-vnc # 保持与 docker-compose.yml 中的 image 名称一致 + container_name: nexus-vnc + ports: + - "9091:9091" + - "8082:8082" + environment: + GUACD_HOSTNAME: guacd + GUACD_PORT: 4822 + VNC_PORT: 9091 + VNC_WS_PORT: 8082 + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + FRONTEND_URL: ${FRONTEND_URL} + MAIN_BACKEND_URL: ${MAIN_BACKEND_URL} + NODE_ENV: production + networks: + - nexus-terminal-network + depends_on: + - guacd + - backend + guacd: image: guacamole/guacd:latest container_name: nexus-terminal-guacd diff --git a/docker-compose.yml b/docker-compose.yml index 99140ae..cdfd823 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: - "18111:80" depends_on: - backend - - rdp + - remote-gateway networks: - nexus-terminal-network @@ -14,24 +14,24 @@ services: image: heavrnl/nexus-terminal-backend:latest container_name: nexus-terminal-backend env_file: - - .env + - .env environment: NODE_ENV: production PORT: 3001 - RDP_BACKEND_API_BASE: http://rdp:9090 + REMOTE_GATEWAY_API_BASE: http://remote-gateway:9090 volumes: - ./data:/app/data networks: - nexus-terminal-network - rdp: - image: heavrnl/nexus-terminal-rdp:latest - container_name: nexus-terminal-rdp + remote-gateway: + image: heavrnl/nexus-terminal-remote-gateway:latest + container_name: nexus-terminal-remote-gateway environment: GUACD_HOST: guacd GUACD_PORT: 4822 - API_PORT: 9090 - GUAC_WS_PORT: 8081 + REMOTE_GATEWAY_API_PORT: 9090 + REMOTE_GATEWAY_WS_PORT: 8080 FRONTEND_URL: http://frontend MAIN_BACKEND_URL: http://backend:3001 NODE_ENV: production diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 8143458..a6509be 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -9,7 +9,7 @@ COPY package.json package-lock.json ./ # Copy workspace package.json files to ensure npm ci works correctly in monorepo COPY packages/backend/package.json ./packages/backend/ COPY packages/frontend/package.json ./packages/frontend/ -COPY packages/rdp/package.json ./packages/rdp/ +COPY packages/remote-gateway/package.json ./packages/remote-gateway/ # Install dependencies (using install instead of ci for potential armv7/alpine compatibility issues) RUN npm install diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index 4583cdd..2356021 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import * as ConnectionService from '../services/connection.service'; import * as SshService from '../services/ssh.service'; +import * as GuacamoleService from '../services/guacamole.service'; // 导入 GuacamoleService import * as ImportExportService from '../services/import-export.service'; import * as ConnectionRepository from '../repositories/connection.repository'; // +++ 导入 ConnectionRepository +++ @@ -269,10 +270,9 @@ export const importConnections = async (req: Request, res: Response): Promise(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 令牌返回给前端 + // 5. 将 Guacamole 令牌返回给前端 res.status(200).json({ token: guacamoleToken }); } catch (error: any) { - console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error); + console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message); let statusCode = 500; - let message = '获取 RDP 会话令牌时发生内部服务器错误。'; + let responseMessage = '获取 RDP 会话令牌时发生内部服务器错误。'; - if (axios.isAxiosError(error)) { - message = '调用 RDP 后端服务时出错。'; + if (error.message.includes('调用 RDP 后端服务失败') || error.message.includes('从 RDP 后端获取令牌失败') || error.message.includes('调用 Remote Gateway API 时出错 (RDP)')) { + responseMessage = error.message; + if (error.message.includes('(状态: 4')) statusCode = 400; + else if (error.message.includes('(状态: 5')) statusCode = 502; + else statusCode = 503; + } else if (error.message.includes('RDP 连接需要使用密码认证') || error.message.includes('密码解密失败') || error.message.includes('RDP 连接使用密码认证,但密码解密失败或未提供密码')) { + responseMessage = error.message; + statusCode = 400; + } else if (error.message.includes('连接类型必须是 RDP')) { + responseMessage = error.message; + statusCode = 400; + } + else if (axios.isAxiosError(error)) { + responseMessage = '调用远程桌面网关服务时发生网络或请求错误。'; 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 + console.error('[Controller:getRdpSessionToken] Remote Gateway error response:', error.response.data); + responseMessage += ` (状态: ${error.response.status})`; + statusCode = error.response.status >= 500 ? 502 : 400; } 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); + console.error('[Controller:getRdpSessionToken] No response from Remote Gateway.'); + responseMessage += ' (无法连接或超时)'; + statusCode = 504; } } else if (error.message.includes('解密失败')) { - message = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。'; + responseMessage = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。'; } - - res.status(statusCode).json({ message }); + res.status(statusCode).json({ message: responseMessage }); } }; +/** + * 获取 VNC 会话的 Guacamole 令牌 (通过调用 Guacamole 服务) + * GET /api/v1/connections/:id/vnc-session + */ +export const getVncSessionToken = async (req: Request, res: Response): Promise => { + try { + const connectionId = parseInt(req.params.id, 10); + if (isNaN(connectionId)) { + res.status(400).json({ message: '无效的连接 ID。' }); + return; + } + + const connectionData = await ConnectionService.getConnectionWithDecryptedCredentials(connectionId); + + if (!connectionData) { + res.status(404).json({ message: '连接未找到。' }); + return; + } + + const { connection, decryptedPassword } = connectionData; + + if (connection.type !== 'VNC') { + res.status(400).json({ message: '此连接类型不是 VNC。' }); + return; + } + + try { + const currentTimeSeconds = Math.floor(Date.now() / 1000); + await ConnectionRepository.updateLastConnected(connectionId, currentTimeSeconds); + console.log(`[Controller:getVncSessionToken] 已更新 VNC 连接 ${connectionId} 的 last_connected_at 为 ${currentTimeSeconds}`); + } catch (updateError) { + console.error(`[Controller:getVncSessionToken] 更新 VNC 连接 ${connectionId} 的 last_connected_at 时出错:`, updateError); + } + + if (connection.auth_method !== 'password' || !decryptedPassword) { + console.warn(`[Controller:getVncSessionToken] VNC connection ${connectionId} does not use password auth or password decryption failed.`); + res.status(400).json({ message: 'VNC 连接需要使用密码认证,或密码解密失败。' }); + return; + } + + const { width, height } = req.query; + const initialWidth = width ? parseInt(width as string, 10) : undefined; + const initialHeight = height ? parseInt(height as string, 10) : undefined; + + const guacamoleToken = await GuacamoleService.getRemoteDesktopToken('vnc', connection, decryptedPassword, initialWidth, initialHeight); + + console.log(`[Controller:getVncSessionToken] Received Guacamole token via GuacamoleService for VNC connection ${connectionId} with size ${initialWidth}x${initialHeight}`); + + res.status(200).json({ token: guacamoleToken }); + + } catch (error: any) { + console.error(`Controller: 获取 VNC 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message); + + let statusCode = 500; + let responseMessage = '获取 VNC 会话令牌时发生内部服务器错误。'; + + if (error.message.includes('调用 VNC 后端服务失败') || error.message.includes('从 VNC 后端获取令牌失败') || error.message.includes('调用 Remote Gateway API 时出错 (VNC)')) { + responseMessage = error.message; + if (error.message.includes('(状态: 4')) statusCode = 400; + else if (error.message.includes('(状态: 5')) statusCode = 502; + else statusCode = 503; + } else if (error.message.includes('VNC 连接需要使用密码认证') || error.message.includes('密码解密失败') || error.message.includes('VNC 连接使用密码认证,但密码解密失败或未提供密码')) { + responseMessage = error.message; + statusCode = 400; + } else if (error.message.includes('连接类型必须是 VNC')) { + responseMessage = error.message; + statusCode = 400; + } + else if (axios.isAxiosError(error)) { + responseMessage = '调用远程桌面网关服务时发生网络或请求错误。'; + if (error.response) { + console.error('[Controller:getVncSessionToken] Remote Gateway error response:', error.response.data); + responseMessage += ` (状态: ${error.response.status})`; + statusCode = error.response.status >= 500 ? 502 : 400; + } else if (error.request) { + console.error('[Controller:getVncSessionToken] No response from Remote Gateway.'); + responseMessage += ' (无法连接或超时)'; + statusCode = 504; + } + } else if (error.message.includes('解密失败')) { + responseMessage = '获取 VNC 会话令牌时发生内部错误(凭证处理失败)。'; + } + res.status(statusCode).json({ message: responseMessage }); + } +}; /** * 克隆连接 (POST /api/v1/connections/:id/clone) */ diff --git a/packages/backend/src/connections/connections.routes.ts b/packages/backend/src/connections/connections.routes.ts index 5098d6c..f1cfd78 100644 --- a/packages/backend/src/connections/connections.routes.ts +++ b/packages/backend/src/connections/connections.routes.ts @@ -12,6 +12,7 @@ import { exportConnections, importConnections, getRdpSessionToken, // Import the new controller function + getVncSessionToken, // Import the VNC session token controller function cloneConnection, // +++ Import the clone controller function +++ // updateConnectionTags, // No longer directly used by primary flow addTagToConnections // +++ Import the new controller function for adding tag to multiple connections +++ @@ -83,6 +84,9 @@ router.post('/test-unsaved', testUnsavedConnection); // POST /api/v1/connections/:id/rdp-session - Get RDP session token via backend router.post('/:id/rdp-session', getRdpSessionToken); +// POST /api/v1/connections/:id/vnc-session - Get VNC session token +router.post('/:id/vnc-session', getVncSessionToken); + // +++ POST /api/v1/connections/:id/clone - 克隆连接 +++ router.post('/:id/clone', cloneConnection); diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index 6a47164..029c453 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -40,6 +40,16 @@ const columnExists = async (db: Database, tableName: string, columnName: string) }); }; +// 辅助函数:获取表的创建 SQL +const getTableCreateSQL = async (db: Database, tableName: string): Promise => { + return new Promise((resolve, reject) => { + db.get("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", [tableName], (err, row: any) => { + if (err) reject(err); + else resolve(row ? row.sql : null); + }); + }); +}; + const definedMigrations: Migration[] = [ { @@ -121,6 +131,102 @@ const definedMigrations: Migration[] = [ ALTER TABLE connections ADD COLUMN notes TEXT NULL; ` }, + { + id: 5, + name: 'Update connections table to allow VNC type in CHECK constraint', + check: async (db: Database): Promise => { + const createSQL = await getTableCreateSQL(db, 'connections'); + if (createSQL) { + // 检查 CHECK 约束是否已经包含了 VNC + // 这会检查 'VNC' 是否是允许的类型之一 + // 例如: CHECK(type IN ('SSH', 'RDP', 'VNC')) + const constraintRegex = /CHECK\s*\(\s*LOWER\(type\)\s+IN\s*\(([^)]+)\)\s*\)/i; // 兼容大小写不敏感的检查 + const constraintRegexStrict = /CHECK\s*\(\s*type\s+IN\s*\(([^)]+)\)\s*\)/i; + + let match = createSQL.match(constraintRegex); + if (!match) { + match = createSQL.match(constraintRegexStrict); + } + + if (match && match[1]) { + const allowedTypes = match[1].split(',').map(t => t.trim().replace(/'/g, "").toLowerCase()); + return !allowedTypes.includes('vnc'); // 如果 'vnc' 不在允许类型中,则需要运行迁移 + } + // 如果没有找到明确的 CHECK 约束或格式不匹配,保守地运行迁移 + console.warn('[Migrations] Check for VNC in connections.type: Could not parse CHECK constraint from SQL. Assuming migration is needed.'); + return true; + } + console.warn('[Migrations] Check for VNC in connections.type: Could not get table create SQL. Assuming migration is needed.'); + return true; // 如果表不存在或无法获取 SQL,则运行迁移 + }, + sql: ` + PRAGMA foreign_keys=off; + + -- 步骤 1: 重命名旧表 + ALTER TABLE connections RENAME TO connections_old_for_vnc_constraint_update; + ALTER TABLE connection_tags RENAME TO connection_tags_old_for_vnc_constraint_update; + + -- 步骤 2: 创建新表 (与 schema.ts 中的定义一致) + CREATE TABLE connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NULL, + type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')) DEFAULT 'SSH', + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')), + encrypted_password TEXT NULL, + encrypted_private_key TEXT NULL, + encrypted_passphrase TEXT NULL, + proxy_id INTEGER NULL, + ssh_key_id INTEGER NULL, + notes TEXT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + last_connected_at INTEGER NULL, + FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL, + FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL + ); + + CREATE TABLE connection_tags ( + connection_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (connection_id, tag_id), + FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + + -- 步骤 3: 从旧表复制数据到新表 + INSERT INTO connections ( + id, name, type, host, port, username, auth_method, + encrypted_password, encrypted_private_key, encrypted_passphrase, + proxy_id, ssh_key_id, notes, created_at, updated_at, last_connected_at + ) + SELECT + id, name, + CASE + WHEN UPPER(type) = 'RDP' THEN 'RDP' + WHEN UPPER(type) = 'SSH' THEN 'SSH' + WHEN UPPER(type) = 'VNC' THEN 'VNC' + ELSE 'SSH' + END, + host, port, username, auth_method, + encrypted_password, encrypted_private_key, encrypted_passphrase, + proxy_id, ssh_key_id, notes, created_at, updated_at, last_connected_at + FROM connections_old_for_vnc_constraint_update; + + INSERT INTO connection_tags (connection_id, tag_id) + SELECT connection_id, tag_id FROM connection_tags_old_for_vnc_constraint_update; + + -- 步骤 4: 删除旧表 + DROP TABLE connections_old_for_vnc_constraint_update; + DROP TABLE connection_tags_old_for_vnc_constraint_update; + + PRAGMA foreign_keys=on; + + ANALYZE; -- 重新分析数据库模式 + ` + }, // --- 未来可以添加更多迁移 --- ]; diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index b9f3be6..aa153ab 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -76,7 +76,7 @@ export const createConnectionsTableSQL = ` CREATE TABLE IF NOT EXISTS connections ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NULL, -- 允许 name 为空 - type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP')) DEFAULT 'SSH', + type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')) DEFAULT 'SSH', host TEXT NOT NULL, port INTEGER NOT NULL, username TEXT NOT NULL, diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a7daa95..bc9ae36 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,10 +1,40 @@ +import dotenv from 'dotenv'; +import path from 'path'; +import fs from 'fs'; // fs is needed for early env loading if data/.env is checked + +// --- 开始环境变量的早期加载 --- +// 1. 加载根目录的 .env 文件 (定义部署模式等) +// 注意: __dirname 在 dist/src 中,所以需要回退三级到项目根目录 +const projectRootEnvPath = path.resolve(__dirname, '../../../.env'); +const rootConfigResult = dotenv.config({ path: projectRootEnvPath }); + +if (rootConfigResult.error && (rootConfigResult.error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn(`[ENV Init Early] Warning: Could not load root .env file from ${projectRootEnvPath}. Error: ${rootConfigResult.error.message}`); +} else if (!rootConfigResult.error) { + console.log(`[ENV Init Early] Loaded environment variables from root .env file: ${projectRootEnvPath}`); +} else { + console.log(`[ENV Init Early] Root .env file not found at ${projectRootEnvPath}, proceeding without it.`); +} + +// 2. 加载 data/.env 文件 (定义密钥等) +// 注意: 这个路径是相对于编译后的 dist/src/index.js +const dataEnvPathGlobal = path.resolve(__dirname, '../data/.env'); // Renamed to avoid conflict if 'dataEnvPath' is used later +const dataConfigResultGlobal = dotenv.config({ path: dataEnvPathGlobal }); // Renamed + +if (dataConfigResultGlobal.error && (dataConfigResultGlobal.error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn(`[ENV Init Early] Warning: Could not load data .env file from ${dataEnvPathGlobal}. Error: ${dataConfigResultGlobal.error.message}`); +} else if (!dataConfigResultGlobal.error) { + console.log(`[ENV Init Early] Loaded environment variables from data .env file: ${dataEnvPathGlobal}`); +} +// --- 结束环境变量的早期加载 --- + import express = require('express'); import { Request, Response, NextFunction, RequestHandler } from 'express'; import http from 'http'; -import fs from 'fs'; -import path from 'path'; +// import fs from 'fs'; // Moved up +// import path from 'path'; // Moved up import crypto from 'crypto'; -import dotenv from 'dotenv'; +// import dotenv from 'dotenv'; // Moved up import session from 'express-session'; import sessionFileStore from 'session-file-store'; import { getDbInstance } from './database/connection'; @@ -54,38 +84,16 @@ process.on('unhandledRejection', (reason: any, promise: Promise) => { const initializeEnvironment = async () => { - // 1. 加载根目录的 .env 文件 (定义部署模式等) - // 注意: __dirname 在 dist/src 中,所以需要回退三级到项目根目录 - const projectRootEnvPath = path.resolve(__dirname, '../../../.env'); - const rootConfigResult = dotenv.config({ path: projectRootEnvPath }); - // Use type assertion for error code checking - if (rootConfigResult.error && (rootConfigResult.error as NodeJS.ErrnoException).code !== 'ENOENT') { - // 只在文件存在但无法加载时发出警告 - console.warn(`[ENV Init] Warning: Could not load root .env file from ${projectRootEnvPath}. Error: ${rootConfigResult.error.message}`); - } else if (!rootConfigResult.error) { - console.log(`[ENV Init] Loaded environment variables from root .env file: ${projectRootEnvPath}`); - } else { - console.log(`[ENV Init] Root .env file not found at ${projectRootEnvPath}, proceeding without it (expected in non-local deployments where env vars are injected).`); - } + // Env files (root and data/.env) are now loaded at the very top of the file. + // This function will now focus on generating keys if they are missing + // and setting defaults for GUACD variables. - // 2. 加载 data/.env 文件 (定义密钥等) - // 注意: 这个路径是相对于编译后的 dist/src/index.js - const dataEnvPath = path.resolve(__dirname, '../data/.env'); + // Use the globally defined path for data .env + const dataEnvPath = dataEnvPathGlobal; // Use the path defined at the top let keysGenerated = false; let keysToAppend = ''; - // dotenv.config 默认不会覆盖已存在的 process.env 变量 - // 这意味着如果根 .env 和 data/.env 定义了相同的变量,先加载的(根 .env)的值会优先 - const dataConfigResult = dotenv.config({ path: dataEnvPath }); - // Use type assertion for error code checking - if (dataConfigResult.error && (dataConfigResult.error as NodeJS.ErrnoException).code !== 'ENOENT') { - // 只在文件存在但无法加载时发出警告,文件不存在是正常情况 - console.warn(`[ENV Init] Warning: Could not load data .env file from ${dataEnvPath}. Error: ${dataConfigResult.error.message}`); - } else if (!dataConfigResult.error) { - console.log(`[ENV Init] Loaded environment variables from data .env file: ${dataEnvPath}`); - } - - // 2. 检查 ENCRYPTION_KEY + // 检查 ENCRYPTION_KEY (process.env should be populated by early loading) if (!process.env.ENCRYPTION_KEY) { console.log('[ENV Init] ENCRYPTION_KEY 未设置,正在生成...'); const newEncryptionKey = crypto.randomBytes(32).toString('hex'); diff --git a/packages/backend/src/repositories/connection.repository.ts b/packages/backend/src/repositories/connection.repository.ts index 3e652c3..0055fb8 100644 --- a/packages/backend/src/repositories/connection.repository.ts +++ b/packages/backend/src/repositories/connection.repository.ts @@ -7,7 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn interface ConnectionBase { id: number; name: string | null; - type: 'SSH' | 'RDP'; // Add type field + type: 'SSH' | 'RDP' | 'VNC'; // Add type field host: string; port: number; username: string; diff --git a/packages/backend/src/services/connection.service.ts b/packages/backend/src/services/connection.service.ts index 16b2730..ef7ddf6 100644 --- a/packages/backend/src/services/connection.service.ts +++ b/packages/backend/src/services/connection.service.ts @@ -43,9 +43,9 @@ export const createConnection = async (input: CreateConnectionInput): Promise> = {}; let needsCredentialUpdate = false; // Determine the final type, converting input type to uppercase if provided - const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | undefined || currentFullConnection.type; + const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined || currentFullConnection.type; // 更新非凭证字段 if (input.name !== undefined) dataToUpdate.name = input.name || ''; @@ -280,20 +303,32 @@ if (input.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes up dataToUpdate.encrypted_password = null; } } - } else { // targetType is 'RDP' + } else if (targetType === 'RDP') { // targetType is 'RDP' // RDP only uses password if (input.password !== undefined) { // Check if password was provided - // Encrypt if password is not empty, otherwise set to null (to clear) dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null; needsCredentialUpdate = true; } // Ensure SSH specific fields are nullified if switching to RDP or updating RDP - if (targetType !== currentFullConnection.type || needsCredentialUpdate) { + if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) { dataToUpdate.auth_method = 'password'; // RDP uses password auth method in DB dataToUpdate.encrypted_private_key = null; dataToUpdate.encrypted_passphrase = null; dataToUpdate.ssh_key_id = null; // RDP cannot use ssh_key_id } + } else { // targetType is 'VNC' + // VNC only uses password + if (input.password !== undefined) { // Check if password was provided + dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null; + needsCredentialUpdate = true; + } + // Ensure SSH specific fields are nullified if switching to VNC or updating VNC + if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) { + dataToUpdate.auth_method = 'password'; // VNC uses password auth method in DB + dataToUpdate.encrypted_private_key = null; + dataToUpdate.encrypted_passphrase = null; + dataToUpdate.ssh_key_id = null; // VNC cannot use ssh_key_id + } } // 3. 如果有更改,则更新连接记录 diff --git a/packages/backend/src/services/guacamole.service.ts b/packages/backend/src/services/guacamole.service.ts index ac999de..d991da2 100644 --- a/packages/backend/src/services/guacamole.service.ts +++ b/packages/backend/src/services/guacamole.service.ts @@ -1 +1,93 @@ -// This file is intentionally left blank as Guacamole logic is handled by the separate rdp package. \ No newline at end of file +import axios from 'axios'; +import { ConnectionWithTags } from '../types/connection.types'; + +// 统一远程桌面网关服务的 Base URL +const REMOTE_GATEWAY_API_BASE = process.env.DEPLOYMENT_MODE === 'local' + ? process.env.REMOTE_GATEWAY_API_BASE_LOCAL || 'http://localhost:9090' + : process.env.REMOTE_GATEWAY_API_BASE_DOCKER || 'http://remote-gateway:9090'; + +console.log(`[GuacamoleService] DEPLOYMENT_MODE: ${process.env.DEPLOYMENT_MODE}`); +console.log(`[GuacamoleService] Using Remote Gateway API Base (Local): ${process.env.REMOTE_GATEWAY_API_BASE_LOCAL}`); +console.log(`[GuacamoleService] Using Remote Gateway API Base (Docker): ${process.env.REMOTE_GATEWAY_API_BASE_DOCKER}`); +console.log(`[GuacamoleService] Effective Remote Gateway API Base: ${REMOTE_GATEWAY_API_BASE}`); + +interface TokenResponse { + token: string; +} + +/** + * 从统一远程桌面网关服务获取 Guacamole 令牌 + * @param protocol 'rdp' 或 'vnc' + * @param connection 连接对象 + * @param decryptedPassword 解密后的密码 + * @param width 宽度 + * @param height 高度 + * @param dpi DPI (主要用于 RDP) + * @returns Guacamole 令牌 + */ +export const getRemoteDesktopToken = async ( + protocol: 'rdp' | 'vnc', + connection: ConnectionWithTags, + decryptedPassword?: string, + width?: number, + height?: number, + dpi?: string // DPI 主要用于 RDP +): Promise => { + if ((protocol === 'rdp' || protocol === 'vnc') && connection.auth_method === 'password' && !decryptedPassword) { + console.warn(`[GuacamoleService:getRemoteDesktopToken] ${protocol.toUpperCase()} connection ${connection.id} uses password auth but password decryption failed or password not provided.`); + throw new Error(`${protocol.toUpperCase()} 连接使用密码认证,但密码解密失败或未提供密码。`); + } + + const connectionConfig: any = { + hostname: connection.host, + port: connection.port.toString(), + width: String(width || 1024), // 提供默认值 + height: String(height || 768), // 提供默认值 + }; + + if (protocol === 'rdp') { + if (!connection.username) { + console.warn(`[GuacamoleService:getRemoteDesktopToken] RDP connection ${connection.id} is missing username.`); + // 对于RDP,用户名通常是必需的,但让网关决定是否可以为空 + } + connectionConfig.username = connection.username || ''; // RDP 通常需要用户名 + connectionConfig.password = decryptedPassword || ''; // RDP 通常需要密码 + connectionConfig.dpi = dpi || '96'; + connectionConfig.security = (connection as any).rdp_security || 'any'; + connectionConfig.ignoreCert = String((connection as any).rdp_ignore_cert ?? true); + } else if (protocol === 'vnc') { + connectionConfig.password = decryptedPassword || ''; // VNC 通常需要密码 + if (connection.username) { // VNC 用户名是可选的 + connectionConfig.username = connection.username; + } + // 其他 VNC 特定参数可以从 connection.extras 获取 + // 例如: if (connection.extras?.enableAudio) connectionConfig.enableAudio = connection.extras.enableAudio; + } + + const requestBody = { + protocol, + connectionConfig + }; + + const tokenUrl = `${REMOTE_GATEWAY_API_BASE}/api/remote-desktop/token`; + console.log(`[GuacamoleService:getRemoteDesktopToken] Calling Remote Gateway API: ${tokenUrl} for protocol ${protocol}, connection ${connection.id}`); + + try { + const response = await axios.post(tokenUrl, requestBody, { + timeout: 10000 // 10 秒超时 + }); + + if (response.status !== 200 || !response.data?.token) { + console.error(`[GuacamoleService:getRemoteDesktopToken] ${protocol.toUpperCase()} backend API call failed or returned invalid data. Status: ${response.status}`, response.data); + throw new Error(`从 ${protocol.toUpperCase()} 后端获取令牌失败。`); + } + console.log(`[GuacamoleService:getRemoteDesktopToken] Received Guacamole token from ${protocol.toUpperCase()} backend for connection ${connection.id}`); + return response.data.token; + } catch (error: any) { + console.error(`[GuacamoleService:getRemoteDesktopToken] Error calling ${protocol.toUpperCase()} backend for connection ${connection.id}:`, error.message); + if (axios.isAxiosError(error) && error.response) { + throw new Error(`调用 ${protocol.toUpperCase()} 后端服务失败 (状态: ${error.response.status}): ${error.response.data?.message || error.message}`); + } + throw new Error(`调用 ${protocol.toUpperCase()} 后端服务时发生错误: ${error.message}`); + } +}; diff --git a/packages/backend/src/services/import-export.service.ts b/packages/backend/src/services/import-export.service.ts index 55ad270..9339662 100644 --- a/packages/backend/src/services/import-export.service.ts +++ b/packages/backend/src/services/import-export.service.ts @@ -7,7 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn interface ImportedConnectionData { name: string; - type: 'SSH' | 'RDP'; // Add type field + type: 'SSH' | 'RDP' | 'VNC'; // Add type field host: string; port: number; username: string; @@ -158,7 +158,7 @@ export const importConnections = async (fileBuffer: Buffer): Promise { - console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${rdpTargetUrl} 时出错:`, error); + console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${remoteDesktopTargetUrl} 时出错:`, error); if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { console.log(`[RDP 代理] 因 RDP WS 错误关闭客户端 WS。会话: ${ws.sessionId}`); ws.close(1011, `RDP WS Error: ${error.message}`); @@ -610,7 +611,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re rdpWs.on('close', (code, reason) => { rdpWsClosed = true; // --- 添加中文日志 --- - console.log(`[RDP 代理 RDP WS 关闭] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已关闭。代码: ${code}, 原因: ${reason.toString()}`); + console.log(`[RDP 代理 RDP WS 关闭] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${remoteDesktopTargetUrl} 的连接已关闭。代码: ${code}, 原因: ${reason.toString()}`); // --- 结束日志 --- if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { console.log(`[RDP 代理] 因 RDP WS 关闭而关闭客户端 WS。会话: ${ws.sessionId}`); @@ -620,7 +621,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re }); rdpWs.on('open', () => { - console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已建立。开始转发消息。`); + console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${remoteDesktopTargetUrl} 的连接已建立。开始转发消息。`); }); // --- 标准 (SSH/SFTP/Docker) 连接处理 --- diff --git a/packages/frontend/Dockerfile b/packages/frontend/Dockerfile index 786663c..69bfe9a 100644 --- a/packages/frontend/Dockerfile +++ b/packages/frontend/Dockerfile @@ -9,7 +9,7 @@ COPY package.json package-lock.json ./ # Copy workspace package.json files to ensure npm ci works correctly in monorepo COPY packages/backend/package.json ./packages/backend/ COPY packages/frontend/package.json ./packages/frontend/ -COPY packages/rdp/package.json ./packages/rdp/ +COPY packages/remote-gateway/package.json ./packages/remote-gateway/ # Install dependencies (using install instead of ci for potential armv7 compatibility issues) RUN npm install diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 261d22a..a59b30b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@nexus-terminal/frontend", - "version": "0.3.5", + "version": "0.4", "private": true, "type": "module", "scripts": { diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue index 6b3d174..c25139a 100644 --- a/packages/frontend/src/App.vue +++ b/packages/frontend/src/App.vue @@ -20,6 +20,8 @@ import StyleCustomizer from './components/StyleCustomizer.vue'; import FocusSwitcherConfigurator from './components/FocusSwitcherConfigurator.vue'; // +++ 导入 RDP 模态框组件 +++ import RemoteDesktopModal from './components/RemoteDesktopModal.vue'; +// +++ 导入 VNC 模态框组件 +++ +import VncModal from './components/VncModal.vue'; const { t } = useI18n(); const authStore = useAuthStore(); @@ -33,7 +35,7 @@ const { showPopupFileEditorBoolean } = storeToRefs(settingsStore); const { isStyleCustomizerVisible } = storeToRefs(appearanceStore); const { isLayoutVisible, isHeaderVisible } = storeToRefs(layoutStore); // 添加 isHeaderVisible const { isConfiguratorVisible: isFocusSwitcherVisible } = storeToRefs(focusSwitcherStore); -const { isRdpModalOpen, rdpConnectionInfo } = storeToRefs(sessionStore); // +++ 获取 RDP 状态 +++ +const { isRdpModalOpen, rdpConnectionInfo, isVncModalOpen, vncConnectionInfo } = storeToRefs(sessionStore); // +++ 获取 RDP 和 VNC 状态 +++ const breakpoints = useBreakpoints(breakpointsTailwind); // +++ Initialize Breakpoints +++ const isMobile = breakpoints.smaller('md'); // +++ Define isMobile +++ @@ -319,6 +321,13 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => { @close="sessionStore.closeRdpModal()" /> + + + diff --git a/packages/frontend/src/components/AddConnectionForm.vue b/packages/frontend/src/components/AddConnectionForm.vue index f403f6d..68ccf51 100644 --- a/packages/frontend/src/components/AddConnectionForm.vue +++ b/packages/frontend/src/components/AddConnectionForm.vue @@ -30,7 +30,7 @@ const { isLoading: isSshKeyLoading, error: sshKeyStoreError } = storeToRefs(sshK // 表单数据模型 const initialFormData = { - type: 'SSH' as 'SSH' | 'RDP', // Use uppercase to match ConnectionInfo + type: 'SSH' as 'SSH' | 'RDP' | 'VNC', // Use uppercase to match ConnectionInfo name: '', host: '', port: 22, @@ -42,7 +42,8 @@ const initialFormData = { selected_ssh_key_id: null as number | null, // +++ Add field for selected key ID +++ proxy_id: null as number | null, tag_ids: [] as number[], // 新增 tag_ids 字段 -notes: '', // 新增备注字段 + notes: '', // 新增备注字段 + vncPassword: '', // VNC specific password // Add RDP specific fields later if needed, e.g., domain }; const formData = reactive({ ...initialFormData }); @@ -79,7 +80,7 @@ watch(() => props.connectionToEdit, (newVal) => { formError.value = null; // 清除错误 if (newVal) { // 编辑模式:填充表单,但不填充敏感信息 - formData.type = newVal.type; // Correctly set the type for editing + formData.type = newVal.type as 'SSH' | 'RDP' | 'VNC'; // Correctly set the type for editing formData.name = newVal.name; formData.host = newVal.host; formData.port = newVal.port; @@ -90,23 +91,34 @@ formData.notes = newVal.notes ?? ''; // 填充备注 formData.tag_ids = newVal.tag_ids ? [...newVal.tag_ids] : []; // 填充 tag_ids (深拷贝) // +++ 填充 selected_ssh_key_id (如果认证方式是 key) +++ - if (newVal.auth_method === 'key') { + if (newVal.type === 'SSH' && newVal.auth_method === 'key') { formData.selected_ssh_key_id = newVal.ssh_key_id ?? null; } else { - formData.selected_ssh_key_id = null; // 清空,以防之前是 key + formData.selected_ssh_key_id = null; // 清空 + } + + // 清空敏感字段 + formData.password = ''; // For SSH/RDP + formData.private_key = ''; + formData.passphrase = ''; + // formData.vncPassword is already handled by initialFormData or cleared if not VNC + if (newVal.type !== 'VNC') { + formData.vncPassword = ''; + } else { + // If editing a VNC connection, we don't get vncPassword from backend directly. + // User needs to re-enter if they want to change it. + // For display, it's kept empty. + formData.vncPassword = ''; } - // 清空敏感字段 (密码和直接输入的密钥) - formData.password = ''; - formData.private_key = ''; // 即使是 key 认证,编辑时也不显示旧的直接输入密钥 - formData.passphrase = ''; // 同上 } else { // 添加模式:重置表单 Object.assign(formData, initialFormData); formData.tag_ids = []; // 确保 tag_ids 也被重置为空数组 formData.selected_ssh_key_id = null; // 确保添加模式下也重置 -formData.notes = ''; // 重置备注 + formData.notes = ''; // 重置备注 + formData.vncPassword = ''; // 重置VNC密码 } }, { immediate: true }); @@ -120,15 +132,17 @@ onMounted(() => { // 监听连接类型变化,动态调整默认端口 watch(() => formData.type, (newType) => { // Use uppercase for comparison - if (newType === 'RDP' && formData.port === 22) { - formData.port = 3389; // RDP 默认端口 - } else if (newType === 'SSH' && formData.port === 3389) { - formData.port = 22; // SSH 默认端口 - } - // 重置或调整认证方式等逻辑可以在这里添加 if (newType === 'RDP') { - // RDP 通常只用密码,可以强制或隐藏 auth_method - // formData.auth_method = 'password'; // Example: Force password for RDP + if (formData.port === 22 || formData.port === 5900 || formData.port === 5901) formData.port = 3389; // RDP 默认端口 + formData.auth_method = 'password'; // RDP uses password + formData.selected_ssh_key_id = null; // Clear SSH key selection + } else if (newType === 'SSH') { + if (formData.port === 3389 || formData.port === 5900 || formData.port === 5901) formData.port = 22; // SSH 默认端口 + // auth_method will be handled by its own select + } else if (newType === 'VNC') { + if (formData.port === 22 || formData.port === 3389) formData.port = 5900; // VNC 默认端口 (e.g., 5900 or 5901) + formData.auth_method = 'password'; // VNC uses password, hide auth_method selector + formData.selected_ssh_key_id = null; // Clear SSH key selection } }); @@ -190,11 +204,18 @@ const handleSubmit = async () => { // RDP Validation // 1. 添加模式下,密码是必填的 if (!isEditMode.value && !formData.password) { - formError.value = t('connections.form.errorPasswordRequired'); // 可以复用密码必填的翻译 + formError.value = t('connections.form.errorPasswordRequired'); return; } - // 2. 编辑模式下,密码可以不填(表示不修改),除非是从非 RDP 类型切换过来(这个逻辑比较复杂,暂时简化为密码非必填) - // 如果需要更严格的验证(例如从 SSH 编辑为 RDP 时强制要求输入密码),可以在这里添加 + // 2. 编辑模式下,密码可以不填(表示不修改) + } else if (formData.type === 'VNC') { + // VNC Validation + // 1. 添加模式下,VNC密码是必填的 + if (!isEditMode.value && !formData.vncPassword) { + formError.value = t('connections.form.errorVncPasswordRequired', 'VNC 密码是必填项。'); // Add new translation key + return; + } + // 2. 编辑模式下,VNC密码可以不填(表示不修改) } // --- 验证逻辑结束 --- @@ -256,6 +277,21 @@ notes: formData.notes, // 添加备注 delete dataToSend.auth_method; delete dataToSend.private_key; delete dataToSend.passphrase; + delete dataToSend.vncPassword; // Ensure VNC password field for form doesn't go if RDP + } else if (formData.type === 'VNC') { + // VNC data population + if (formData.vncPassword) { + dataToSend.password = formData.vncPassword; // Backend expects VNC password in 'password' field + } else if (isEditMode.value && formData.vncPassword === '') { + // Editing VNC, empty password means don't change + } + // VNC does not use SSH specific fields + delete dataToSend.auth_method; + delete dataToSend.private_key; + delete dataToSend.passphrase; + delete dataToSend.ssh_key_id; + // formData.vncPassword is used for the form, but backend might expect it as 'password' + // So, we don't send 'vncPassword' itself to backend. } @@ -424,6 +460,7 @@ const testButtonText = computed(() => { style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;"> + @@ -498,13 +535,22 @@ const testButtonText = computed(() => { class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" /> --> + + + + - +

{{ t('connections.form.sectionAdvanced', '高级选项') }}

-
+