From cc413590934b6524cc1bbb35f6b91e60b0128ea0 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Mon, 28 Apr 2025 15:06:53 +0800 Subject: [PATCH] update --- .gitignore | 3 + docker-compose.yml | 2 + package-lock.json | 11 +- packages/backend/package.json | 4 +- packages/backend/src/index.ts | 47 ++-- packages/backend/src/websocket.ts | 203 +++++++++++++++--- .../src/components/RemoteDesktopModal.vue | 10 +- 7 files changed, 221 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 1ed3fc5..c332571 100644 --- a/.gitignore +++ b/.gitignore @@ -157,3 +157,6 @@ dist-scripts/ # Ignore specific generated file in root /tsc + +# Environment variables +.env diff --git a/docker-compose.yml b/docker-compose.yml index d5488c0..07830e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,8 @@ services: build: context: . dockerfile: packages/backend/Dockerfile + env_file: + - .env # Load environment variables from .env file in the root container_name: nexus-terminal-backend ports: - "18112:3001" diff --git a/package-lock.json b/package-lock.json index 47905f6..328ce0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -623,6 +623,10 @@ "resolved": "packages/frontend", "link": true }, + "node_modules/@nexus-terminal/rdp": { + "resolved": "packages/rdp", + "link": true + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2057,10 +2061,6 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/backend": { - "resolved": "packages/rdp", - "link": true - }, "node_modules/bagpipe": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz", @@ -7611,6 +7611,7 @@ "sqlite3": "^5.1.7", "ssh2": "^1.16.0", "uuid": "^11.1.0", + "ws": "^8.18.1", "xterm": "^5.3.0" }, "devDependencies": { @@ -7666,7 +7667,7 @@ } }, "packages/rdp": { - "name": "backend", + "name": "@nexus-terminal/rdp", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/packages/backend/package.json b/packages/backend/package.json index 34f1a58..520b449 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -31,8 +31,8 @@ "sqlite3": "^5.1.7", "ssh2": "^1.16.0", "uuid": "^11.1.0", - "xterm": "^5.3.0", - "ws": "^8.18.1" + "ws": "^8.18.1", + "xterm": "^5.3.0" }, "devDependencies": { "@types/bcrypt": "^5.0.2", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 449c098..93415e6 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -30,13 +30,36 @@ import './services/notification.dispatcher.service'; // 确保分发器被加载 // --- 结束通知系统初始化 --- // --- 环境变量和密钥初始化 --- const initializeEnvironment = async () => { - const rootEnvPath = path.resolve(__dirname, '../data/.env'); // 指向项目根目录的 .env + // 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).`); + } + + // 2. 加载 data/.env 文件 (定义密钥等) + // 注意: 这个路径是相对于编译后的 dist/src/index.js + const dataEnvPath = path.resolve(__dirname, '../data/.env'); let keysGenerated = false; let keysToAppend = ''; - // 1. 尝试加载根目录的 .env 文件 (如果存在) - // dotenv.config 不会覆盖已存在的 process.env 变量 - dotenv.config({ path: rootEnvPath }); + // 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 if (!process.env.ENCRYPTION_KEY) { @@ -76,20 +99,20 @@ const initializeEnvironment = async () => { // 5. 如果生成了新密钥或添加了默认值,则追加到 .env 文件 if (keysGenerated) { try { - // 确保追加前有换行符 (如果文件非空) + // 确保追加前有换行符 (如果文件非空) - Use dataEnvPath here let prefix = ''; - if (fs.existsSync(rootEnvPath)) { - const content = fs.readFileSync(rootEnvPath, 'utf-8'); + if (fs.existsSync(dataEnvPath)) { // Use dataEnvPath + const content = fs.readFileSync(dataEnvPath, 'utf-8'); // Use dataEnvPath if (content.trim().length > 0 && !content.endsWith('\n')) { prefix = '\n'; } } - fs.appendFileSync(rootEnvPath, prefix + keysToAppend.trim()); // trim() 移除开头的换行符 - console.warn(`[ENV Init] 已自动生成密钥并保存到 ${rootEnvPath}`); - console.warn('[ENV Init] !!! 重要:请务必备份此 .env 文件,并在生产环境中妥善保管 !!!'); + fs.appendFileSync(dataEnvPath, prefix + keysToAppend.trim()); // Use dataEnvPath, trim() 移除开头的换行符 + console.warn(`[ENV Init] 已自动生成密钥并保存到 ${dataEnvPath}`); // Use dataEnvPath + console.warn('[ENV Init] !!! 重要:请务必备份此 data/.env 文件,并在生产环境中妥善保管 !!!'); } catch (error) { - console.error(`[ENV Init] 无法写入密钥到 ${rootEnvPath}:`, error); - console.error('[ENV Init] 请检查文件权限或手动创建 .env 文件并添加生成的密钥。'); + console.error(`[ENV Init] 无法写入密钥到 ${dataEnvPath}:`, error); // Use dataEnvPath + console.error('[ENV Init] 请检查文件权限或手动创建 data/.env 文件并添加生成的密钥。'); // 即使写入失败,密钥已在 process.env 中,程序可以继续运行本次 } } diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 3d2cacf..30b0976 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -1,5 +1,9 @@ -import WebSocket, { WebSocketServer } from 'ws'; +import WebSocket, { WebSocketServer, RawData } from 'ws'; import http from 'http'; +import url from 'url'; +// path and dotenv are no longer needed here as env vars are loaded in index.ts +// import path from 'path'; +// import dotenv from 'dotenv'; import { Request, RequestHandler } from 'express'; import { Client, ClientChannel } from 'ssh2'; import { v4 as uuidv4 } from 'uuid'; @@ -372,10 +376,13 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available: -export const initializeWebSocket = async (server: http.Server, sessionParser: RequestHandler): Promise => { +export const initializeWebSocket = async (server: http.Server, sessionParser: RequestHandler): Promise => { + // Environment variables (including DEPLOYMENT_MODE and RDP URLs) + // are now expected to be loaded by index.ts before this function is called. + const wss = new WebSocketServer({ noServer: true }); - const db = await getDbInstance(); - const DOCKER_STATUS_INTERVAL = 2000; + const db = await getDbInstance(); + const DOCKER_STATUS_INTERVAL = 2000; // --- 心跳检测 --- const heartbeatInterval = setInterval(() => { @@ -393,58 +400,183 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re // --- WebSocket 升级处理 (认证) --- server.on('upgrade', (request: Request, socket, head) => { + const parsedUrl = url.parse(request.url || '', true); // Parse URL and query string + const pathname = parsedUrl.pathname; + const ipAddress = request.ip; // Get IP address early + + console.log(`WebSocket: 升级请求来自 IP: ${ipAddress}, Path: ${pathname}`); + // @ts-ignore Express-session 类型问题 sessionParser(request, {} as any, () => { + // --- 认证检查 --- if (!request.session || !request.session.userId) { - console.log('WebSocket 认证失败:未找到会话或用户未登录。'); + console.log(`WebSocket 认证失败 (Path: ${pathname}):未找到会话或用户未登录。`); socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } - console.log(`WebSocket 认证成功:用户 ${request.session.username} (ID: ${request.session.userId})`); - // 获取客户端 IP 地址 - const ipAddress = request.ip; - console.log(`WebSocket: 升级请求来自 IP: ${ipAddress}`); + console.log(`WebSocket 认证成功 (Path: ${pathname}):用户 ${request.session.username} (ID: ${request.session.userId})`); - wss.handleUpgrade(request, socket, head, (ws) => { - const extWs = ws as AuthenticatedWebSocket; - extWs.userId = request.session.userId; - extWs.username = request.session.username; - // 将 IP 地址附加到 request 对象上传递给 connection 事件处理器,以便后续使用 - (request as any).clientIpAddress = ipAddress; - wss.emit('connection', extWs, request); - }); + // --- 根据路径处理升级 --- + if (pathname === '/rdp-proxy') { + // RDP 代理路径 - 直接处理升级,连接逻辑在 'connection' 事件中处理 + console.log(`WebSocket: Handling RDP proxy upgrade for user ${request.session.username}`); + wss.handleUpgrade(request, socket, head, (ws) => { + const extWs = ws as AuthenticatedWebSocket; + extWs.userId = request.session.userId; + extWs.username = request.session.username; + // 传递必要信息给 connection 事件 + (request as any).clientIpAddress = ipAddress; + (request as any).isRdpProxy = true; // 标记为 RDP 代理连接 + (request as any).rdpToken = parsedUrl.query.token; // 传递 RDP token + wss.emit('connection', extWs, request); + }); + } else { + // 默认路径 (SSH, SFTP, Docker etc.) - 按原逻辑处理 + console.log(`WebSocket: Handling standard upgrade for user ${request.session.username}`); + wss.handleUpgrade(request, socket, head, (ws) => { + const extWs = ws as AuthenticatedWebSocket; + extWs.userId = request.session.userId; + extWs.username = request.session.username; + (request as any).clientIpAddress = ipAddress; + (request as any).isRdpProxy = false; // 标记为非 RDP 代理连接 + wss.emit('connection', extWs, request); + }); + } }); }); // --- WebSocket 连接处理 --- wss.on('connection', (ws: AuthenticatedWebSocket, request: Request) => { ws.isAlive = true; - console.log(`WebSocket:客户端 ${ws.username} (ID: ${ws.userId}) 已连接。`); + const isRdpProxy = (request as any).isRdpProxy; + const clientIp = (request as any).clientIpAddress || 'unknown'; + + console.log(`WebSocket:客户端 ${ws.username} (ID: ${ws.userId}, IP: ${clientIp}, RDP Proxy: ${isRdpProxy}) 已连接。`); ws.on('pong', () => { ws.isAlive = true; }); - // --- 消息处理 --- - ws.on('message', async (message) => { - // console.log(`WebSocket:收到来自 ${ws.username} (会话: ${ws.sessionId}) 的消息: ${message.toString().substring(0, 100)}...`); - let parsedMessage: any; - try { - parsedMessage = JSON.parse(message.toString()); - } catch (e) { - console.error(`WebSocket:来自 ${ws.username} 的无效 JSON 消息:`, message.toString()); - ws.send(JSON.stringify({ type: 'error', payload: '无效的消息格式 (非 JSON)' })); + // --- RDP 代理连接处理 --- + if (isRdpProxy) { + const rdpToken = (request as any).rdpToken; + if (!rdpToken) { + console.error(`WebSocket: RDP Proxy connection for ${ws.username} missing token.`); + ws.send(JSON.stringify({ type: 'rdp:error', payload: 'Missing RDP connection token.' })); + ws.close(1008, 'Missing RDP token'); return; } - const { type, payload, requestId } = parsedMessage; // requestId 用于 SFTP 操作 - const sessionId = ws.sessionId; // 获取当前 WebSocket 的会话 ID - const state = sessionId ? clientStates.get(sessionId) : undefined; // 获取当前会话状态 + // Determine RDP target URL based on deployment mode + const deploymentMode = process.env.DEPLOYMENT_MODE || 'docker'; // Default to docker mode + let rdpBaseUrl: string; + if (deploymentMode === 'local') { + rdpBaseUrl = process.env.RDP_SERVICE_URL_LOCAL || 'ws://localhost:18114'; // Default for local + console.log(`[WebSocket RDP Proxy] Using LOCAL deployment mode. RDP Target Base: ${rdpBaseUrl}`); + } else { + rdpBaseUrl = process.env.RDP_SERVICE_URL_DOCKER || 'ws://rdp:8081'; // Default for docker + console.log(`[WebSocket RDP Proxy] Using DOCKER deployment mode. RDP Target Base: ${rdpBaseUrl}`); + } - try { - switch (type) { - // --- SSH 连接请求 --- - case 'ssh:connect': { - if (sessionId && state) { + // Ensure base URL doesn't end with a slash before appending query params + const cleanRdpBaseUrl = rdpBaseUrl.endsWith('/') ? rdpBaseUrl.slice(0, -1) : rdpBaseUrl; + const rdpTargetUrl = `${cleanRdpBaseUrl}/?token=${rdpToken}`; // Append token query param + + console.log(`WebSocket: RDP Proxy for ${ws.username} attempting to connect to ${rdpTargetUrl}`); + + const rdpWs = new WebSocket(rdpTargetUrl); + let clientWsClosed = false; + let rdpWsClosed = false; + + // --- 消息转发: Client -> RDP --- + ws.on('message', (message: RawData) => { + if (rdpWs.readyState === WebSocket.OPEN) { + // console.log(`RDP Proxy (C->S): Forwarding message from ${ws.username}`); + rdpWs.send(message); + } else { + console.warn(`RDP Proxy (C->S): RDP WS not open, dropping message from ${ws.username}`); + } + }); + + // --- 消息转发: RDP -> Client --- + rdpWs.on('message', (message: RawData) => { + if (ws.readyState === WebSocket.OPEN) { + // 将 RawData (可能是 Buffer) 转换为 UTF-8 字符串再发送 + const messageString = message.toString('utf-8'); + // console.log(`RDP Proxy (S->C): Forwarding message to ${ws.username}: ${messageString.substring(0, 50)}...`); + ws.send(messageString); + } else { + console.warn(`RDP Proxy (S->C): Client WS not open, dropping message for ${ws.username}`); + } + }); + + // --- 错误处理 --- + ws.on('error', (error) => { + console.error(`WebSocket: RDP Proxy Client WS Error for ${ws.username}:`, error); + if (!rdpWsClosed && rdpWs.readyState !== WebSocket.CLOSED && rdpWs.readyState !== WebSocket.CLOSING) { + console.log(`WebSocket: RDP Proxy closing RDP WS due to client WS error.`); + rdpWs.close(1011, 'Client WS Error'); + rdpWsClosed = true; + } + clientWsClosed = true; + }); + rdpWs.on('error', (error) => { + console.error(`WebSocket: RDP Proxy RDP WS Error for ${ws.username}:`, error); + if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { + console.log(`WebSocket: RDP Proxy closing Client WS due to RDP WS error.`); + ws.close(1011, `RDP WS Error: ${error.message}`); + clientWsClosed = true; + } + rdpWsClosed = true; + }); + + // --- 关闭处理 --- + ws.on('close', (code, reason) => { + clientWsClosed = true; + console.log(`WebSocket: RDP Proxy Client WS Closed for ${ws.username}. Code: ${code}, Reason: ${reason.toString()}`); + if (!rdpWsClosed && rdpWs.readyState !== WebSocket.CLOSED && rdpWs.readyState !== WebSocket.CLOSING) { + console.log(`WebSocket: RDP Proxy closing RDP WS due to client WS close.`); + rdpWs.close(1000, 'Client WS Closed'); + rdpWsClosed = true; + } + }); + rdpWs.on('close', (code, reason) => { + rdpWsClosed = true; + console.log(`WebSocket: RDP Proxy RDP WS Closed for ${ws.username}. Code: ${code}, Reason: ${reason.toString()}`); + if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { + console.log(`WebSocket: RDP Proxy closing Client WS due to RDP WS close.`); + ws.close(1000, 'RDP WS Closed'); + clientWsClosed = true; + } + }); + + rdpWs.on('open', () => { + console.log(`WebSocket: RDP Proxy connection to ${rdpTargetUrl} established for ${ws.username}. Forwarding messages.`); + // Do not send custom message, let Guacamole protocol flow directly + }); + + // --- 标准 (SSH/SFTP/Docker) 连接处理 --- + } else { + // --- 消息处理 (原有逻辑) --- + ws.on('message', async (message) => { + // console.log(`WebSocket:收到来自 ${ws.username} (会话: ${ws.sessionId}) 的消息: ${message.toString().substring(0, 100)}...`); + let parsedMessage: any; + try { + parsedMessage = JSON.parse(message.toString()); + } catch (e) { + console.error(`WebSocket:来自 ${ws.username} 的无效 JSON 消息:`, message.toString()); + ws.send(JSON.stringify({ type: 'error', payload: '无效的消息格式 (非 JSON)' })); + return; + } + + const { type, payload, requestId } = parsedMessage; // requestId 用于 SFTP 操作 + const sessionId = ws.sessionId; // 获取当前 WebSocket 的会话 ID + const state = sessionId ? clientStates.get(sessionId) : undefined; // 获取当前会话状态 + + try { + switch (type) { + // --- SSH 连接请求 --- + case 'ssh:connect': { + if (sessionId && state) { console.warn(`WebSocket: 用户 ${ws.username} (会话: ${sessionId}) 已有活动连接,忽略新的连接请求。`); ws.send(JSON.stringify({ type: 'ssh:error', payload: '已存在活动的 SSH 连接。' })); return; @@ -1016,6 +1148,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re console.error(`WebSocket:客户端 ${ws.username} (会话: ${ws.sessionId}) 发生错误:`, error); cleanupClientConnection(ws.sessionId); }); +} // End of else block for non-RDP connections }); // --- WebSocket 服务器关闭处理 --- diff --git a/packages/frontend/src/components/RemoteDesktopModal.vue b/packages/frontend/src/components/RemoteDesktopModal.vue index 413021b..725bf85 100644 --- a/packages/frontend/src/components/RemoteDesktopModal.vue +++ b/packages/frontend/src/components/RemoteDesktopModal.vue @@ -38,13 +38,11 @@ const desiredModalHeight = ref(858); // User sets the desired TOTAL modal height const MIN_MODAL_WIDTH = 1024; const MIN_MODAL_HEIGHT = 768; -const RDP_BACKEND_API_BASE = 'http://localhost:9090'; // This might need adjustment too if API is accessed directly from browser, but currently it's proxied via backend - // Dynamically construct WebSocket URL const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = window.location.hostname; -const wsPort = import.meta.env.VITE_RDP_WEBSOCKET_PORT || '18114'; // Read from env var or use default -const RDP_BACKEND_WEBSOCKET_URL = `${wsProtocol}//${wsHost}:${wsPort}`; +const backendPort = '3001'; // Backend WebSocket port +const BACKEND_WEBSOCKET_URL = `${wsProtocol}//${wsHost}:${backendPort}`; // URL for backend proxy // Removed localStorage keys const connectRdp = async () => { // Removed useInputValues parameter @@ -93,7 +91,9 @@ const connectRdp = async () => { // Removed useInputValues parameter // Consider setting an error state or notifying the user } - const tunnelUrl = `${RDP_BACKEND_WEBSOCKET_URL}/?token=${encodeURIComponent(token)}&width=${widthToSend}&height=${heightToSend}&dpi=${dpiToSend}`; + // Construct URL for the backend proxy endpoint + const tunnelUrl = `${BACKEND_WEBSOCKET_URL}/rdp-proxy?token=${encodeURIComponent(token)}&width=${widthToSend}&height=${heightToSend}&dpi=${dpiToSend}`; + console.log(`[RDP Modal] Connecting to tunnel: ${tunnelUrl}`); // Log the final URL // @ts-ignore const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);