feat: 添加终端支持多色和鼠标键盘支持

Related to #1
This commit is contained in:
Baobhan Sith
2025-05-01 15:37:17 +08:00
parent c28a5a6de0
commit 2d7434d778
+72 -66
View File
@@ -36,6 +36,7 @@ export interface ClientState { // 导出以便 Service 可以导入
statusIntervalId?: NodeJS.Timeout; // 添加状态轮询 ID (由 StatusMonitorService 管理) statusIntervalId?: NodeJS.Timeout; // 添加状态轮询 ID (由 StatusMonitorService 管理)
dockerStatusIntervalId?: NodeJS.Timeout; // NEW: Docker 状态轮询 ID dockerStatusIntervalId?: NodeJS.Timeout; // NEW: Docker 状态轮询 ID
ipAddress?: string; // 添加 IP 地址字段 ipAddress?: string; // 添加 IP 地址字段
isShellReady?: boolean; // 新增:标记 Shell 是否已准备好处理输入和调整大小
} }
@@ -524,7 +525,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
// 根据宽高的简单 DPI 计算逻辑 (如果宽度 > 1920,则 DPI=120,否则 DPI=96) // 根据宽高的简单 DPI 计算逻辑 (如果宽度 > 1920,则 DPI=120,否则 DPI=96)
const calculatedDpi = rdpWidth > 1920 ? 120 : 96; const calculatedDpi = rdpWidth > 1920 ? 120 : 96;
console.log(`WebSocket: RDP Proxy calculated DPI for ${ws.username} based on width ${rdpWidth}: ${calculatedDpi}`); console.log(`WebSocket: RDP Proxy calculated DPI for ${ws.username} based on width ${rdpWidth}: ${calculatedDpi}`);
// --- 结束新增 ---
// Determine RDP target URL based on deployment mode // Determine RDP target URL based on deployment mode
@@ -541,9 +542,8 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
console.warn(`[WebSocket RDP Proxy] Unknown deployment mode '${deploymentMode}'. Defaulting to safe fallback RDP Target Base: ${rdpBaseUrl}`); console.warn(`[WebSocket RDP Proxy] Unknown deployment mode '${deploymentMode}'. Defaulting to safe fallback RDP Target Base: ${rdpBaseUrl}`);
} }
// Ensure base URL doesn't end with a slash before appending query params
const cleanRdpBaseUrl = rdpBaseUrl.endsWith('/') ? rdpBaseUrl.slice(0, -1) : rdpBaseUrl; const cleanRdpBaseUrl = rdpBaseUrl.endsWith('/') ? rdpBaseUrl.slice(0, -1) : rdpBaseUrl;
// Append ALL parameters to the target URL, using calculated DPI
const rdpTargetUrl = `${cleanRdpBaseUrl}/?token=${encodeURIComponent(rdpToken)}&width=${encodeURIComponent(rdpWidth)}&height=${encodeURIComponent(rdpHeight)}&dpi=${encodeURIComponent(calculatedDpi)}`; // 使用 calculatedDpi const rdpTargetUrl = `${cleanRdpBaseUrl}/?token=${encodeURIComponent(rdpToken)}&width=${encodeURIComponent(rdpWidth)}&height=${encodeURIComponent(rdpHeight)}&dpi=${encodeURIComponent(calculatedDpi)}`; // 使用 calculatedDpi
console.log(`WebSocket: RDP Proxy for ${ws.username} attempting to connect to ${rdpTargetUrl}`); console.log(`WebSocket: RDP Proxy for ${ws.username} attempting to connect to ${rdpTargetUrl}`);
@@ -555,10 +555,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
// --- 消息转发: Client -> RDP --- // --- 消息转发: Client -> RDP ---
ws.on('message', (message: RawData) => { ws.on('message', (message: RawData) => {
if (rdpWs.readyState === WebSocket.OPEN) { if (rdpWs.readyState === WebSocket.OPEN) {
// --- 添加中文日志 ---
const messageString = message.toString('utf-8'); // 尝试解码为 UTF-8 const messageString = message.toString('utf-8'); // 尝试解码为 UTF-8
// console.log(`[RDP 代理 C->S] 用户: ${ws.username}, 会话: ${ws.sessionId}, 转发消息 (前 100 字符): ${messageString.substring(0, 100)}`);
// --- 结束日志 ---
rdpWs.send(message); rdpWs.send(message);
} else { } else {
console.warn(`[RDP 代理 C->S] 用户: ${ws.username}, 会话: ${ws.sessionId}, RDP WS 未打开,丢弃消息。`); console.warn(`[RDP 代理 C->S] 用户: ${ws.username}, 会话: ${ws.sessionId}, RDP WS 未打开,丢弃消息。`);
@@ -570,9 +567,6 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
// 将 RawData (可能是 Buffer) 转换为 UTF-8 字符串再发送 // 将 RawData (可能是 Buffer) 转换为 UTF-8 字符串再发送
const messageString = message.toString('utf-8'); const messageString = message.toString('utf-8');
// --- 添加中文日志 ---
// console.log(`[RDP 代理 S->C] 用户: ${ws.username}, 会话: ${ws.sessionId}, 转发消息 (前 100 字符): ${messageString.substring(0, 100)}`);
// --- 结束日志 ---
ws.send(messageString); ws.send(messageString);
} else { } else {
console.warn(`[RDP 代理 S->C] 用户: ${ws.username}, 会话: ${ws.sessionId}, 客户端 WS 未打开,丢弃消息。`); console.warn(`[RDP 代理 S->C] 用户: ${ws.username}, 会话: ${ws.sessionId}, 客户端 WS 未打开,丢弃消息。`);
@@ -581,9 +575,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
// --- 错误处理 --- // --- 错误处理 ---
ws.on('error', (error) => { ws.on('error', (error) => {
// --- 添加中文日志 ---
console.error(`[RDP 代理 客户端 WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 错误:`, error); console.error(`[RDP 代理 客户端 WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 错误:`, error);
// --- 结束日志 ---
if (!rdpWsClosed && rdpWs.readyState !== WebSocket.CLOSED && rdpWs.readyState !== WebSocket.CLOSING) { if (!rdpWsClosed && rdpWs.readyState !== WebSocket.CLOSED && rdpWs.readyState !== WebSocket.CLOSING) {
console.log(`[RDP 代理] 因客户端 WS 错误关闭 RDP WS。会话: ${ws.sessionId}`); console.log(`[RDP 代理] 因客户端 WS 错误关闭 RDP WS。会话: ${ws.sessionId}`);
rdpWs.close(1011, 'Client WS Error'); rdpWs.close(1011, 'Client WS Error');
@@ -592,9 +584,8 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
clientWsClosed = true; clientWsClosed = true;
}); });
rdpWs.on('error', (error) => { rdpWs.on('error', (error) => {
// --- 添加中文日志 ---
console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${rdpTargetUrl} 时出错:`, error); console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${rdpTargetUrl} 时出错:`, error);
// --- 结束日志 ---
if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
console.log(`[RDP 代理] 因 RDP WS 错误关闭客户端 WS。会话: ${ws.sessionId}`); console.log(`[RDP 代理] 因 RDP WS 错误关闭客户端 WS。会话: ${ws.sessionId}`);
ws.close(1011, `RDP WS Error: ${error.message}`); ws.close(1011, `RDP WS Error: ${error.message}`);
@@ -628,10 +619,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
}); });
rdpWs.on('open', () => { rdpWs.on('open', () => {
// --- 添加中文日志 ---
console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已建立。开始转发消息。`); console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已建立。开始转发消息。`);
// --- 结束日志 ---
// Do not send custom message, let Guacamole protocol flow directly
}); });
// --- 标准 (SSH/SFTP/Docker) 连接处理 --- // --- 标准 (SSH/SFTP/Docker) 连接处理 ---
@@ -693,39 +681,70 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
sshClient: sshClient, sshClient: sshClient,
dbConnectionId: dbConnectionId, dbConnectionId: dbConnectionId,
ipAddress: clientIp, // 存储 IP 地址 ipAddress: clientIp, // 存储 IP 地址
isShellReady: false, // 初始化 Shell 状态为未就绪
}; };
clientStates.set(newSessionId, newState); clientStates.set(newSessionId, newState);
console.log(`WebSocket: 为用户 ${ws.username} (IP: ${clientIp}) 创建新会话 ${newSessionId} (DB ID: ${dbConnectionId})`); console.log(`WebSocket: 为用户 ${ws.username} (IP: ${clientIp}) 创建新会话 ${newSessionId} (DB ID: ${dbConnectionId})`);
// 4. 打开 Shell // 4. 立即打开 Shell (使用默认尺寸)
ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' })); ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' }));
try { try {
const shellStream = await SshService.openShell(sshClient); // 使用默认尺寸 80x24 打开 Shell
newState.sshShellStream = shellStream; const defaultCols = 80;
const defaultRows = 24;
sshClient.shell({ term: 'xterm-256color', cols: defaultCols, rows: defaultRows }, (err, stream) => {
if (err) {
console.error(`SSH: 会话 ${newSessionId} 打开 Shell 失败:`, err);
// 记录审计日志:打开 Shell 失败
auditLogService.logAction('SSH_SHELL_FAILURE', {
userId: ws.userId,
username: ws.username,
connectionId: dbConnectionId,
sessionId: newSessionId,
ip: newState.ipAddress,
reason: err.message
});
notificationService.sendNotification('SSH_SHELL_FAILURE', { // 添加通知调用
userId: ws.userId,
username: ws.username,
connectionId: dbConnectionId,
sessionId: newSessionId,
ip: newState.ipAddress,
reason: err.message
});
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 失败: ${err.message}` }));
}
cleanupClientConnection(newSessionId); // 清理连接
return;
}
// 5. 设置 Shell 事件转发 // Shell 打开成功
shellStream.on('data', (data: Buffer) => { console.log(`WebSocket: 会话 ${newSessionId} Shell 打开成功 (使用默认尺寸 ${defaultCols}x${defaultRows})。`);
// --- 添加日志:打印收到的原始数据 --- newState.sshShellStream = stream;
// console.log(`SSH Data (会话: ${newSessionId}, 原始): `, data.toString()); // 添加原始数据日志 (尝试 utf8) newState.isShellReady = true; // 标记 Shell 已就绪
// console.log(`SSH Data (会话: ${newSessionId}, Hex): `, data.toString('hex')); // 添加 Hex 日志
// 5. 立即设置 Shell 事件转发 (捕获初始输出)
stream.on('data', (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' }));
} }
}); });
shellStream.stderr.on('data', (data: Buffer) => { stream.stderr.on('data', (data: Buffer) => {
console.error(`SSH Stderr (会话: ${newSessionId}): ${data.toString('utf8').substring(0, 100)}...`); console.error(`SSH Stderr (会话: ${newSessionId}): ${data.toString('utf8').substring(0, 100)}...`);
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' }));
} }
}); });
shellStream.on('close', () => { stream.on('close', () => {
console.log(`SSH: 会话 ${newSessionId} 的 Shell 通道已关闭。`); console.log(`SSH: 会话 ${newSessionId} 的 Shell 通道已关闭。`);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' })); ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' }));
}
cleanupClientConnection(newSessionId); cleanupClientConnection(newSessionId);
}); });
// 6. 发送 SSH 连接成功消息 // 6. 发送 SSH 连接成功消息 (现在 Shell 也已打开)
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: 'ssh:connected', type: 'ssh:connected',
payload: { payload: {
@@ -750,7 +769,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
ip: newState.ipAddress ip: newState.ipAddress
}); });
// 7. 异步初始化 SFTP 和启动状态监控 // 7. 启动异步任务 (SFTP, Status Monitor, Docker)
console.log(`WebSocket: 会话 ${newSessionId} 正在异步初始化 SFTP...`); console.log(`WebSocket: 会话 ${newSessionId} 正在异步初始化 SFTP...`);
sftpService.initializeSftpSession(newSessionId) sftpService.initializeSftpSession(newSessionId)
.then(() => console.log(`SFTP: 会话 ${newSessionId} 异步初始化成功。`)) .then(() => console.log(`SFTP: 会话 ${newSessionId} 异步初始化成功。`))
@@ -759,10 +778,9 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`); console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`);
statusMonitorService.startStatusPolling(newSessionId); statusMonitorService.startStatusPolling(newSessionId);
console.log(`WebSocket: 会话 ${newSessionId} 正在启动 Docker 状态轮询...`); console.log(`WebSocket: 会话 ${newSessionId} 正在启动 Docker 状态轮询...`);
let dockerPollIntervalMs = 2000; let dockerPollIntervalMs = 2000;
(async () => { // 使用 IIFE 获取设置
try { try {
const intervalSetting = await settingsService.getSetting('dockerStatusIntervalSeconds'); const intervalSetting = await settingsService.getSetting('dockerStatusIntervalSeconds');
if (intervalSetting) { if (intervalSetting) {
@@ -780,7 +798,6 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
console.error(`[Docker Polling] Error fetching interval setting for session ${newSessionId}. Using default ${dockerPollIntervalMs}ms:`, settingError); console.error(`[Docker Polling] Error fetching interval setting for session ${newSessionId}. Using default ${dockerPollIntervalMs}ms:`, settingError);
} }
const dockerIntervalId = setInterval(async () => { const dockerIntervalId = setInterval(async () => {
const currentState = clientStates.get(newSessionId); const currentState = clientStates.get(newSessionId);
if (!currentState || currentState.ws.readyState !== WebSocket.OPEN) { if (!currentState || currentState.ws.readyState !== WebSocket.OPEN) {
@@ -789,23 +806,17 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
return; return;
} }
try { try {
const statusPayload = await fetchRemoteDockerStatus(currentState); const statusPayload = await fetchRemoteDockerStatus(currentState);
if (currentState.ws.readyState === WebSocket.OPEN) { if (currentState.ws.readyState === WebSocket.OPEN) {
currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload })); currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload }));
} }
} catch (error: any) { } catch (error: any) {
console.error(`[Docker Polling] Error fetching Docker status for session ${newSessionId}:`, error); console.error(`[Docker Polling] Error fetching Docker status for session ${newSessionId}:`, error);
} }
}, dockerPollIntervalMs); }, dockerPollIntervalMs);
newState.dockerStatusIntervalId = dockerIntervalId; if (newState) newState.dockerStatusIntervalId = dockerIntervalId; // 确保 newState 仍然存在
// 立即触发一次 Docker 状态获取
(async () => {
const currentState = clientStates.get(newSessionId); const currentState = clientStates.get(newSessionId);
if (currentState && currentState.ws.readyState === WebSocket.OPEN) { if (currentState && currentState.ws.readyState === WebSocket.OPEN) {
try { try {
@@ -817,7 +828,6 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
} catch (error: any) { } catch (error: any) {
console.error(`[Docker Initial Fetch] Error fetching Docker status for session ${newSessionId}:`, error); console.error(`[Docker Initial Fetch] Error fetching Docker status for session ${newSessionId}:`, error);
if (currentState.ws.readyState === WebSocket.OPEN) { if (currentState.ws.readyState === WebSocket.OPEN) {
const errorMessage = error.message || 'Unknown error during initial fetch'; const errorMessage = error.message || 'Unknown error during initial fetch';
const isUnavailable = errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon'); const isUnavailable = errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon');
if (isUnavailable) { if (isUnavailable) {
@@ -829,39 +839,27 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
} }
} }
})(); })();
}); // End of sshClient.shell callback
} catch (shellError: any) { } catch (shellError: any) {
console.error(`SSH: 会话 ${newSessionId} 打开 Shell 失败:`, shellError); // 这个 catch 块理论上不会被触发,因为错误在 shell 回调中处理,但保留以防万一
// 记录审计日志:打开 Shell 失败 console.error(`SSH: 会话 ${newSessionId} 打开 Shell 时发生意外错误:`, shellError);
auditLogService.logAction('SSH_SHELL_FAILURE', { if (ws.readyState === WebSocket.OPEN) {
userId: ws.userId, ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 时发生意外错误: ${shellError.message}` }));
username: ws.username, }
connectionId: dbConnectionId,
sessionId: newSessionId,
ip: newState.ipAddress,
reason: shellError.message
});
notificationService.sendNotification('SSH_SHELL_FAILURE', { // 添加通知调用
userId: ws.userId,
username: ws.username,
connectionId: dbConnectionId,
sessionId: newSessionId,
ip: newState.ipAddress,
reason: shellError.message
});
ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 失败: ${shellError.message}` }));
cleanupClientConnection(newSessionId); cleanupClientConnection(newSessionId);
} }
// 10. 设置 SSH Client 的关闭和错误处理 (移到 Shell 成功打开之后) // 8. 设置 SSH Client 的关闭和错误处理
sshClient.on('close', () => { sshClient.on('close', () => {
console.log(`SSH: 会话 ${newSessionId} 的客户端连接已关闭。`); console.log(`SSH: 会话 ${newSessionId} 的客户端连接已关闭。`);
cleanupClientConnection(newSessionId); cleanupClientConnection(newSessionId);
}); });
sshClient.on('error', (err: Error) => { sshClient.on('error', (err: Error) => {
console.error(`SSH: 会话 ${newSessionId} 的客户端连接错误:`, err); console.error(`SSH: 会话 ${newSessionId} 的客户端连接错误:`, err);
// 确保在发送错误消息前检查 WebSocket 是否仍然打开
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ssh:error', payload: `SSH 连接错误: ${err.message}` })); ws.send(JSON.stringify({ type: 'ssh:error', payload: `SSH 连接错误: ${err.message}` }));
}
cleanupClientConnection(newSessionId); cleanupClientConnection(newSessionId);
}); });
@@ -904,14 +902,22 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
// --- SSH 终端大小调整 --- // --- SSH 终端大小调整 ---
case 'ssh:resize': { case 'ssh:resize': {
if (!state || !state.sshShellStream) { if (!sessionId || !state || !state.sshClient) {
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的调整大小请求,但无活动 Shell`); console.warn(`WebSocket: 收到来自 ${ws.username} 的调整大小请求,但无有效会话或 SSH 客户端`);
return; return;
} }
const { cols, rows } = payload || {}; const { cols, rows } = payload || {};
if (typeof cols === 'number' && typeof rows === 'number') { if (typeof cols !== 'number' || typeof rows !== 'number' || cols <= 0 || rows <= 0) {
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的无效调整大小请求:`, payload);
return;
}
if (state.isShellReady && state.sshShellStream) {
console.log(`SSH: 会话 ${sessionId} 调整终端大小: ${cols}x${rows}`); console.log(`SSH: 会话 ${sessionId} 调整终端大小: ${cols}x${rows}`);
state.sshShellStream.setWindow(rows, cols, 0, 0); state.sshShellStream.setWindow(rows, cols, 0, 0); // ssh2 使用 (rows, cols)
} else {
console.warn(`WebSocket: 会话 ${sessionId} 收到调整大小请求,但 Shell 尚未就绪或流不存在 (isShellReady: ${state.isShellReady})。`);
} }
break; break;
} }