This commit is contained in:
Baobhan Sith
2025-04-28 15:06:53 +08:00
parent cfbc124295
commit cc41359093
7 changed files with 221 additions and 59 deletions
+3
View File
@@ -157,3 +157,6 @@ dist-scripts/
# Ignore specific generated file in root
/tsc
# Environment variables
.env
+2
View File
@@ -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"
+6 -5
View File
@@ -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": {
+2 -2
View File
@@ -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",
+35 -12
View File
@@ -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 中,程序可以继续运行本次
}
}
+165 -32
View File
@@ -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';
@@ -373,6 +377,9 @@ const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available:
export const initializeWebSocket = async (server: http.Server, sessionParser: RequestHandler): Promise<WebSocketServer> => {
// 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;
@@ -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 服务器关闭处理 ---
@@ -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);