update
This commit is contained in:
@@ -52,6 +52,7 @@ import terminalThemeRoutes from './terminal-themes/terminal-theme.routes';
|
||||
import appearanceRoutes from './appearance/appearance.routes';
|
||||
import sshKeysRouter from './ssh_keys/ssh_keys.routes'; // +++ Import SSH Key routes +++
|
||||
import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes'; // +++ Import Quick Command Tag routes +++
|
||||
import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes'; // +++ Import SSH Suspend routes +++
|
||||
import { initializeWebSocket } from './websocket';
|
||||
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware';
|
||||
|
||||
@@ -271,6 +272,7 @@ const startServer = () => {
|
||||
app.use('/api/v1/appearance', appearanceRoutes);
|
||||
app.use('/api/v1/ssh-keys', sshKeysRouter); // +++ Register SSH Key routes +++
|
||||
app.use('/api/v1/quick-command-tags', quickCommandTagRoutes); // +++ Register Quick Command Tag routes +++
|
||||
app.use('/api/v1/ssh-suspend', sshSuspendRouter); // +++ Register SSH Suspend routes +++
|
||||
|
||||
// 状态检查接口
|
||||
app.get('/api/v1/status', (req: Request, res: Response) => {
|
||||
|
||||
@@ -382,7 +382,7 @@ export const settingsService = {
|
||||
const validPaneNames: Set<PaneName> = new Set([
|
||||
'connections', 'terminal', 'commandBar', 'fileManager',
|
||||
'editor', 'statusMonitor', 'commandHistory', 'quickCommands',
|
||||
'dockerManager'
|
||||
'dockerManager', 'suspendedSshSessions' // 添加 "suspendedSshSessions"
|
||||
]);
|
||||
|
||||
const validatePaneArray = (arr: any[], side: string) => {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { Client, Channel, ClientChannel } from 'ssh2';
|
||||
import { EventEmitter } from 'events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
SuspendSessionDetails,
|
||||
SuspendedSessionsMap,
|
||||
BackendSshStatus,
|
||||
SuspendedSessionInfo,
|
||||
} from '../types/ssh-suspend.types';
|
||||
import { temporaryLogStorageService, TemporaryLogStorageService } from './temporary-log-storage.service';
|
||||
import { clientStates } from '../websocket/state'; // +++ 导入 clientStates +++
|
||||
|
||||
/**
|
||||
* SshSuspendService 负责管理所有用户的挂起 SSH 会话的生命周期。
|
||||
*/
|
||||
export class SshSuspendService extends EventEmitter {
|
||||
private suspendedSessions: SuspendedSessionsMap = new Map();
|
||||
private readonly logStorageService: TemporaryLogStorageService;
|
||||
|
||||
constructor(logStorage?: TemporaryLogStorageService) {
|
||||
super(); // 调用 EventEmitter 的构造函数
|
||||
this.logStorageService = logStorage || temporaryLogStorageService;
|
||||
// TODO: 考虑在服务启动时从日志目录加载持久化的 'disconnected_by_backend' 会话信息。
|
||||
// 这需要日志文件本身包含可解析的元数据。
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户特定的会话映射,如果不存在则创建。
|
||||
* @param userId 用户ID。
|
||||
* @returns 该用户的 Map<suspendSessionId, SuspendSessionDetails>。
|
||||
*/
|
||||
private getUserSessions(userId: number): Map<string, SuspendSessionDetails> { // userId: string -> number
|
||||
if (!this.suspendedSessions.has(userId)) {
|
||||
this.suspendedSessions.set(userId, new Map<string, SuspendSessionDetails>());
|
||||
}
|
||||
return this.suspendedSessions.get(userId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动指定 SSH 会话的挂起模式。
|
||||
* @param userId 用户ID。
|
||||
* @param originalSessionId 原始会话ID。
|
||||
* @param sshClient SSH 客户端实例。
|
||||
* @param channel SSH 通道实例。
|
||||
* @param connectionName 连接名称。
|
||||
* @param connectionId 连接ID。
|
||||
* @param customSuspendName 可选的自定义挂起名称。
|
||||
* @returns Promise<string> 返回生成的 suspendSessionId。
|
||||
*/
|
||||
async startSuspend(
|
||||
userId: number, // userId: string -> number
|
||||
originalSessionId: string,
|
||||
sshClient: Client,
|
||||
channel: ClientChannel, // 更新为 ClientChannel
|
||||
connectionName: string,
|
||||
connectionId: string,
|
||||
customSuspendName?: string,
|
||||
): Promise<string> {
|
||||
const suspendSessionId = uuidv4();
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
|
||||
// 在接管 channel 和 sshClient 前,移除它们上面可能存在的旧监听器
|
||||
// 这确保了 SshSuspendService 独占事件处理,避免旧的处理器(如 ssh.handler.ts 中的)继续发送数据或处理关闭事件
|
||||
channel.removeAllListeners('data');
|
||||
channel.removeAllListeners('close');
|
||||
channel.removeAllListeners('error');
|
||||
channel.removeAllListeners('end'); // ClientChannel 也有 'end' 事件
|
||||
channel.removeAllListeners('exit'); // ClientChannel 也有 'exit' 事件
|
||||
|
||||
// 对于 sshClient,移除监听器需要谨慎,特别是如果 sshClient 实例可能被多个 Shell共享(尽管在此应用中通常不这么做)
|
||||
// 假设这里的 sshClient 的生命周期与此 channel 紧密相关,或者是此 channel 的唯一父级。
|
||||
sshClient.removeAllListeners('error');
|
||||
sshClient.removeAllListeners('end');
|
||||
// sshClient.removeAllListeners('close'); // sshClient 本身没有 'close' 事件,通常是 'end' 或连接错误
|
||||
|
||||
const tempLogPath = `./data/temp_suspended_ssh_logs/${suspendSessionId}.log`; // 路径相对于项目根目录
|
||||
|
||||
const sessionDetails: SuspendSessionDetails = {
|
||||
sshClient,
|
||||
channel,
|
||||
tempLogPath,
|
||||
connectionName,
|
||||
connectionId,
|
||||
suspendStartTime: new Date().toISOString(),
|
||||
customSuspendName,
|
||||
backendSshStatus: 'hanging',
|
||||
originalSessionId,
|
||||
userId,
|
||||
};
|
||||
|
||||
userSessions.set(suspendSessionId, sessionDetails);
|
||||
|
||||
// +++ 更新 ClientState 标记 +++
|
||||
const originalClientState = clientStates.get(originalSessionId);
|
||||
if (originalClientState) {
|
||||
originalClientState.isSuspendedByService = true;
|
||||
console.log(`[用户: ${userId}] ClientState for session ${originalSessionId} marked as suspended by service.`);
|
||||
} else {
|
||||
console.warn(`[用户: ${userId}] Could not find ClientState for original session ID ${originalSessionId} to mark as suspended.`);
|
||||
}
|
||||
// +++ 结束更新 ClientState 标记 +++
|
||||
|
||||
console.log(`[用户: ${userId}] SSH会话 ${originalSessionId} (连接: ${connectionName}) 已启动挂起,ID: ${suspendSessionId}`);
|
||||
|
||||
// 确保日志目录存在
|
||||
await this.logStorageService.ensureLogDirectoryExists();
|
||||
|
||||
// 开始监听通道数据并写入日志
|
||||
channel.on('data', (data: Buffer) => {
|
||||
if (userSessions.get(suspendSessionId)?.backendSshStatus === 'hanging') {
|
||||
this.logStorageService.writeToLog(suspendSessionId, data.toString('utf-8')).catch(err => {
|
||||
console.error(`[用户: ${userId}, 会话: ${suspendSessionId}] 写入挂起日志失败:`, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleUnexpectedClose = () => {
|
||||
const currentSession = userSessions.get(suspendSessionId);
|
||||
if (currentSession && currentSession.backendSshStatus === 'hanging') {
|
||||
const reason = 'SSH connection closed or errored.';
|
||||
console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] SSH 连接意外断开。原因: ${reason}`);
|
||||
currentSession.backendSshStatus = 'disconnected_by_backend';
|
||||
currentSession.disconnectionTimestamp = new Date().toISOString();
|
||||
|
||||
this.removeChannelListeners(channel, sshClient); // 使用辅助方法移除
|
||||
|
||||
// 发出事件通知 WebSocket 层
|
||||
this.emit('sessionAutoTerminated', {
|
||||
userId: currentSession.userId, // 使用存储在 sessionDetails 中的 userId
|
||||
suspendSessionId,
|
||||
reason
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
channel.on('close', handleUnexpectedClose);
|
||||
channel.on('error', (err: Error) => {
|
||||
console.error(`[用户: ${userId}, 会话: ${suspendSessionId}] 通道错误:`, err);
|
||||
handleUnexpectedClose();
|
||||
});
|
||||
sshClient.on('error', (err: Error) => {
|
||||
console.error(`[用户: ${userId}, 会话: ${suspendSessionId}] SSH客户端错误:`, err);
|
||||
handleUnexpectedClose();
|
||||
});
|
||||
sshClient.on('end', () => { // 'end' 通常是正常关闭,但也需要处理
|
||||
console.log(`[用户: ${userId}, 会话: ${suspendSessionId}] SSH客户端连接结束。`);
|
||||
handleUnexpectedClose(); // 如果是意外的,则标记为 disconnected
|
||||
});
|
||||
|
||||
|
||||
return suspendSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助方法:移除会话相关的事件监听器。
|
||||
*/
|
||||
private removeChannelListeners(channel: Channel, sshClient: Client): void {
|
||||
channel.removeAllListeners('data');
|
||||
channel.removeAllListeners('close');
|
||||
channel.removeAllListeners('error');
|
||||
sshClient.removeAllListeners('error');
|
||||
sshClient.removeAllListeners('end');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 列出指定用户的所有挂起会话(包括活跃和已断开的)。
|
||||
* 目前主要从内存中获取信息。
|
||||
* @param userId 用户ID。
|
||||
* @returns Promise<SuspendedSessionInfo[]> 挂起会话信息的数组。
|
||||
*/
|
||||
async listSuspendedSessions(userId: number): Promise<SuspendedSessionInfo[]> { // userId: string -> number
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const sessionsInfo: SuspendedSessionInfo[] = [];
|
||||
|
||||
for (const [suspendSessionId, details] of userSessions.entries()) {
|
||||
sessionsInfo.push({
|
||||
suspendSessionId,
|
||||
connectionName: details.connectionName,
|
||||
connectionId: details.connectionId,
|
||||
suspendStartTime: details.suspendStartTime,
|
||||
customSuspendName: details.customSuspendName,
|
||||
backendSshStatus: details.backendSshStatus,
|
||||
disconnectionTimestamp: details.disconnectionTimestamp,
|
||||
});
|
||||
}
|
||||
// TODO: 增强此方法以从日志目录恢复 'disconnected_by_backend' 的会话状态,
|
||||
// 这需要日志文件包含元数据。
|
||||
return sessionsInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复指定的挂起会话。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 要恢复的挂起会话ID。
|
||||
* @returns Promise<{ sshClient: Client; channel: ClientChannel; logData: string; connectionName: string; originalConnectionId: string; } | null> 恢复成功则返回客户端、通道、日志数据、连接名和原始连接ID,否则返回null。
|
||||
*/
|
||||
async resumeSession(userId: number, suspendSessionId: string): Promise<{ sshClient: Client; channel: ClientChannel; logData: string; connectionName: string; originalConnectionId: string; } | null> {
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (!session || session.backendSshStatus !== 'hanging') {
|
||||
console.warn(`[用户: ${userId}] 尝试恢复的会话 ${suspendSessionId} 不存在或状态不正确 (${session?.backendSshStatus})。`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 停止监听旧通道事件
|
||||
this.removeChannelListeners(session.channel, session.sshClient);
|
||||
|
||||
const logData = await this.logStorageService.readLog(suspendSessionId);
|
||||
|
||||
// 在从 userSessions 删除会话之前,保存需要返回的会话详细信息
|
||||
const { sshClient, channel, connectionName, connectionId: originalConnectionId } = session;
|
||||
|
||||
userSessions.delete(suspendSessionId);
|
||||
await this.logStorageService.deleteLog(suspendSessionId);
|
||||
|
||||
console.log(`[用户: ${userId}] 挂起会话 ${suspendSessionId} 已成功恢复。`);
|
||||
return {
|
||||
sshClient,
|
||||
channel,
|
||||
logData,
|
||||
connectionName,
|
||||
originalConnectionId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 终止一个活跃的挂起会话。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 要终止的挂起会话ID。
|
||||
* @returns Promise<boolean> 操作是否成功。
|
||||
*/
|
||||
async terminateSuspendedSession(userId: number, suspendSessionId: string): Promise<boolean> { // userId: string -> number
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (!session || session.backendSshStatus !== 'hanging') {
|
||||
console.warn(`[用户: ${userId}] 尝试终止的会话 ${suspendSessionId} 不存在或不是活跃状态 (${session?.backendSshStatus})。`);
|
||||
// 如果会话已断开,但记录还在,也应该能被“终止”(即移除)
|
||||
if(session && session.backendSshStatus === 'disconnected_by_backend'){
|
||||
userSessions.delete(suspendSessionId);
|
||||
await this.logStorageService.deleteLog(suspendSessionId);
|
||||
console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} 已通过终止操作移除。`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeChannelListeners(session.channel, session.sshClient);
|
||||
|
||||
try {
|
||||
session.channel.close(); // 尝试优雅关闭
|
||||
} catch (e) {
|
||||
console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] 关闭channel时出错:`, e);
|
||||
}
|
||||
try {
|
||||
session.sshClient.end(); // 尝试优雅关闭
|
||||
} catch (e) {
|
||||
console.warn(`[用户: ${userId}, 会话: ${suspendSessionId}] 关闭sshClient时出错:`, e);
|
||||
}
|
||||
|
||||
userSessions.delete(suspendSessionId);
|
||||
await this.logStorageService.deleteLog(suspendSessionId);
|
||||
|
||||
console.log(`[用户: ${userId}] 活跃的挂起会话 ${suspendSessionId} 已成功终止并移除。`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一个已断开的挂起会话条目。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 要移除的挂起会话ID。
|
||||
* @returns Promise<boolean> 操作是否成功。
|
||||
*/
|
||||
async removeDisconnectedSessionEntry(userId: number, suspendSessionId: string): Promise<boolean> { // userId: string -> number
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (session && session.backendSshStatus === 'hanging') {
|
||||
console.warn(`[用户: ${userId}] 尝试移除的会话 ${suspendSessionId} 仍处于活跃状态,请先终止。`);
|
||||
return false; // 不允许直接移除活跃会话,应先终止
|
||||
}
|
||||
|
||||
// 如果会话在内存中(不论状态),则删除
|
||||
if (session) {
|
||||
userSessions.delete(suspendSessionId);
|
||||
}
|
||||
|
||||
// 总是尝试删除日志文件,因为它可能对应一个已不在内存中的断开会话
|
||||
try {
|
||||
await this.logStorageService.deleteLog(suspendSessionId);
|
||||
console.log(`[用户: ${userId}] 已断开的挂起会话条目 ${suspendSessionId} 的日志已删除 (内存中状态: ${session ? session.backendSshStatus : '不在内存'})。`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[用户: ${userId}] 删除会话 ${suspendSessionId} 的日志文件失败:`, error);
|
||||
// 即便日志删除失败,如果内存条目已删,也算部分成功。但严格来说应返回false。
|
||||
// 如果 session 不在内存中,但日志删除成功,也算成功。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑挂起会话的自定义名称。
|
||||
* 目前仅更新内存中的名称。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 挂起会话ID。
|
||||
* @param newCustomName 新的自定义名称。
|
||||
* @returns Promise<boolean> 操作是否成功。
|
||||
*/
|
||||
async editSuspendedSessionName(userId: number, suspendSessionId: string, newCustomName: string): Promise<boolean> { // userId: string -> number
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (!session) {
|
||||
console.warn(`[用户: ${userId}] 尝试编辑名称的会话 ${suspendSessionId} 不存在。`);
|
||||
return false;
|
||||
}
|
||||
|
||||
session.customSuspendName = newCustomName;
|
||||
console.log(`[用户: ${userId}] 挂起会话 ${suspendSessionId} 的自定义名称已更新为: ${newCustomName}`);
|
||||
// TODO: 如果设计要求将自定义名称持久化到日志文件的元数据部分,
|
||||
// 此处需要添加更新日志文件的逻辑。这可能涉及读取、修改元数据、然后重写文件。
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理特定会话的 SSH 连接意外断开。
|
||||
* 此方法主要由内部事件监听器调用。
|
||||
* @param userId 用户ID。
|
||||
* @param suspendSessionId 发生断开的会话ID。
|
||||
*/
|
||||
public handleUnexpectedDisconnection(userId: number, suspendSessionId: string): void { // userId: string -> number
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
const session = userSessions.get(suspendSessionId);
|
||||
|
||||
if (session && session.backendSshStatus === 'hanging') {
|
||||
const reason = 'Unexpected disconnection handled by SshSuspendService.';
|
||||
session.backendSshStatus = 'disconnected_by_backend';
|
||||
session.disconnectionTimestamp = new Date().toISOString();
|
||||
this.removeChannelListeners(session.channel, session.sshClient); // 移除监听器
|
||||
console.log(`[用户: ${userId}] 会话 ${suspendSessionId} 状态更新为 'disconnected_by_backend'。原因: ${reason}`);
|
||||
|
||||
this.emit('sessionAutoTerminated', {
|
||||
userId: session.userId,
|
||||
suspendSessionId,
|
||||
reason
|
||||
});
|
||||
// 确保所有已缓冲的日志已尝试写入 (通常由 'data' 事件处理,这里是最终状态确认)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例模式导出
|
||||
export const sshSuspendService = new SshSuspendService();
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const MAX_LOG_SIZE_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
const LOG_DIRECTORY = './data/temp_suspended_ssh_logs/';
|
||||
|
||||
/**
|
||||
* TemporaryLogStorageService负责管理临时日志文件的原子化读、写、删除及轮替操作。
|
||||
*/
|
||||
export class TemporaryLogStorageService {
|
||||
constructor() {
|
||||
this.ensureLogDirectoryExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保日志目录存在,如果不存在则创建它。
|
||||
*/
|
||||
async ensureLogDirectoryExists(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(LOG_DIRECTORY, { recursive: true });
|
||||
// console.log(`日志目录 '${LOG_DIRECTORY}' 已确保存在。`);
|
||||
} catch (error) {
|
||||
console.error(`创建日志目录 '${LOG_DIRECTORY}' 失败:`, error);
|
||||
// 在实际应用中,这里可能需要更健壮的错误处理
|
||||
}
|
||||
}
|
||||
|
||||
private getLogFilePath(suspendSessionId: string): string {
|
||||
return path.join(LOG_DIRECTORY, `${suspendSessionId}.log`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据写入指定挂起会话的日志文件。
|
||||
* 如果文件大小超过MAX_LOG_SIZE_BYTES,将采取轮替策略(清空并从头开始写)。
|
||||
* @param suspendSessionId - 挂起会话的ID。
|
||||
* @param data - 要写入的数据。
|
||||
*/
|
||||
async writeToLog(suspendSessionId: string, data: string): Promise<void> {
|
||||
const filePath = this.getLogFilePath(suspendSessionId);
|
||||
try {
|
||||
await this.ensureLogDirectoryExists(); // 确保目录存在
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(filePath);
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
throw e;
|
||||
}
|
||||
// 文件不存在,是正常情况,后续会创建
|
||||
}
|
||||
|
||||
if (stat && stat.size >= MAX_LOG_SIZE_BYTES) {
|
||||
// 文件过大,执行轮替策略:清空文件
|
||||
console.log(`日志文件 '${filePath}' 大小达到 ${MAX_LOG_SIZE_BYTES / (1024 * 1024)}MB,执行轮替(清空)。`);
|
||||
await fs.writeFile(filePath, data, 'utf8'); // 清空并写入新数据
|
||||
} else {
|
||||
await fs.appendFile(filePath, data, 'utf8');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`写入日志文件 '${filePath}' 失败:`, error);
|
||||
throw error; // 重新抛出错误,让调用者处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取指定挂起会话的日志文件内容。
|
||||
* @param suspendSessionId - 挂起会话的ID。
|
||||
* @returns 返回日志文件的内容。如果文件不存在,则返回空字符串。
|
||||
*/
|
||||
async readLog(suspendSessionId: string): Promise<string> {
|
||||
const filePath = this.getLogFilePath(suspendSessionId);
|
||||
try {
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// console.log(`日志文件 '${filePath}' 不存在,返回空内容。`);
|
||||
return ''; // 文件不存在,通常意味着没有日志
|
||||
}
|
||||
console.error(`读取日志文件 '${filePath}' 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定挂起会话的日志文件。
|
||||
* @param suspendSessionId - 挂起会话的ID。
|
||||
*/
|
||||
async deleteLog(suspendSessionId: string): Promise<void> {
|
||||
const filePath = this.getLogFilePath(suspendSessionId);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
// console.log(`日志文件 '${filePath}' 已成功删除。`);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// console.warn(`尝试删除日志文件 '${filePath}' 时发现文件已不存在,操作忽略。`);
|
||||
return; // 文件不存在,无需操作
|
||||
}
|
||||
console.error(`删除日志文件 '${filePath}' 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出日志目录中的所有日志文件名(不含扩展名,即suspendSessionId)。
|
||||
* 这可以用于 `SshSuspendService` 初始化时加载已断开的会话。
|
||||
* @returns 返回包含所有 suspendSessionId 的数组。
|
||||
*/
|
||||
async listLogFiles(): Promise<string[]> {
|
||||
try {
|
||||
await this.ensureLogDirectoryExists();
|
||||
const files = await fs.readdir(LOG_DIRECTORY);
|
||||
return files
|
||||
.filter(file => file.endsWith('.log'))
|
||||
.map(file => file.replace(/\.log$/, ''));
|
||||
} catch (error) {
|
||||
console.error(`列出日志目录 '${LOG_DIRECTORY}' 中的文件失败:`, error);
|
||||
return []; // 发生错误时返回空数组
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单例模式导出
|
||||
export const temporaryLogStorageService = new TemporaryLogStorageService();
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { sshSuspendService } from '../services/ssh-suspend.service'; // 导入单例服务
|
||||
import { SuspendedSessionInfo } from '../types/ssh-suspend.types'; // 导入类型
|
||||
|
||||
export class SshSuspendController {
|
||||
// private sshSuspendService: SshSuspendService; // 不再需要,直接使用导入的单例
|
||||
|
||||
constructor() {
|
||||
// this.sshSuspendService = new SshSuspendService(); // 不再需要实例化
|
||||
// 绑定方法到当前实例,以确保 'this' 上下文正确
|
||||
this.getSuspendedSshSessions = this.getSuspendedSshSessions.bind(this);
|
||||
this.terminateAndRemoveSession = this.terminateAndRemoveSession.bind(this);
|
||||
this.removeSessionEntry = this.removeSessionEntry.bind(this);
|
||||
}
|
||||
|
||||
public async getSuspendedSshSessions(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.session.userId;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: 'Unauthorized. User ID not found in session.' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SshSuspendController] getSuspendedSshSessions called for user ID: ${userId}`);
|
||||
|
||||
const sessions: SuspendedSessionInfo[] = await sshSuspendService.listSuspendedSessions(userId);
|
||||
res.status(200).json(sessions);
|
||||
} catch (error) {
|
||||
console.error(`[SshSuspendController] Error fetching suspended SSH sessions for user ID: ${req.session.userId}:`, error);
|
||||
if (error instanceof Error) {
|
||||
res.status(500).json({ message: 'Failed to fetch suspended SSH sessions', error: error.message });
|
||||
} else {
|
||||
res.status(500).json({ message: 'Failed to fetch suspended SSH sessions', error: 'Unknown error' });
|
||||
}
|
||||
}
|
||||
} // Closes getSuspendedSshSessions
|
||||
|
||||
public async terminateAndRemoveSession(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.session.userId;
|
||||
const { suspendSessionId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: 'Unauthorized. User ID not found in session.' });
|
||||
return;
|
||||
}
|
||||
if (!suspendSessionId) {
|
||||
res.status(400).json({ message: 'Bad Request. suspendSessionId parameter is missing.' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SshSuspendController] terminateAndRemoveSession called for user ID: ${userId}, suspendSessionId: ${suspendSessionId}`);
|
||||
|
||||
const success = await sshSuspendService.terminateSuspendedSession(userId, suspendSessionId);
|
||||
if (success) {
|
||||
res.status(200).json({ message: `Suspended session ${suspendSessionId} terminated and removed successfully.` });
|
||||
} else {
|
||||
// The service logs warnings for non-existent or wrong state sessions,
|
||||
// which might be a 404 or a 409 depending on the exact cause.
|
||||
// For simplicity, returning 404 if not successful, assuming it means "not found or not in correct state to terminate".
|
||||
res.status(404).json({ message: `Failed to terminate and remove session ${suspendSessionId}. It might not exist or not be in a 'hanging' state.` });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SshSuspendController] Error terminating session for user ID: ${req.session.userId}, suspendSessionId: ${req.params.suspendSessionId}:`, error);
|
||||
if (error instanceof Error) {
|
||||
res.status(500).json({ message: 'Failed to terminate suspended session', error: error.message });
|
||||
} else {
|
||||
res.status(500).json({ message: 'Failed to terminate suspended session', error: 'Unknown error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async removeSessionEntry(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.session.userId;
|
||||
const { suspendSessionId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: 'Unauthorized. User ID not found in session.' });
|
||||
return;
|
||||
}
|
||||
if (!suspendSessionId) {
|
||||
res.status(400).json({ message: 'Bad Request. suspendSessionId parameter is missing.' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SshSuspendController] removeSessionEntry called for user ID: ${userId}, suspendSessionId: ${suspendSessionId}`);
|
||||
|
||||
const success = await sshSuspendService.removeDisconnectedSessionEntry(userId, suspendSessionId);
|
||||
if (success) {
|
||||
res.status(200).json({ message: `Suspended session entry ${suspendSessionId} removed successfully.` });
|
||||
} else {
|
||||
// Similar to terminate, if not successful, it might be due to various reasons logged by the service.
|
||||
// Returning 404 if not successful, assuming it means "not found or not in correct state to remove".
|
||||
res.status(404).json({ message: `Failed to remove session entry ${suspendSessionId}. It might not exist or was still 'hanging'.` });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SshSuspendController] Error removing session entry for user ID: ${req.session.userId}, suspendSessionId: ${req.params.suspendSessionId}:`, error);
|
||||
if (error instanceof Error) {
|
||||
res.status(500).json({ message: 'Failed to remove suspended session entry', error: error.message });
|
||||
} else {
|
||||
res.status(500).json({ message: 'Failed to remove suspended session entry', error: 'Unknown error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import express from 'express';
|
||||
import { SshSuspendController } from './ssh-suspend.controller';
|
||||
import { isAuthenticated } from '../auth/auth.middleware'; // 取消注释:如果需要认证
|
||||
|
||||
const router = express.Router();
|
||||
const sshSuspendController = new SshSuspendController();
|
||||
|
||||
// 定义获取挂起 SSH 会话列表的路由
|
||||
// 路径将是 /api/v1/ssh-suspend/suspended-sessions (因为基础路径是 /api/v1/ssh-suspend)
|
||||
router.get(
|
||||
'/suspended-sessions',
|
||||
isAuthenticated, // 取消注释:添加认证中间件
|
||||
sshSuspendController.getSuspendedSshSessions
|
||||
);
|
||||
|
||||
// Route to terminate an active 'hanging' suspended session and remove its entry
|
||||
router.delete(
|
||||
'/terminate/:suspendSessionId',
|
||||
isAuthenticated,
|
||||
sshSuspendController.terminateAndRemoveSession
|
||||
);
|
||||
|
||||
// Route to remove an already 'disconnected_by_backend' suspended session entry
|
||||
router.delete(
|
||||
'/entry/:suspendSessionId',
|
||||
isAuthenticated,
|
||||
sshSuspendController.removeSessionEntry
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,7 +1,7 @@
|
||||
// packages/backend/src/types/settings.types.ts
|
||||
|
||||
// Define PaneName here as it's logically related to layout/sidebar settings
|
||||
export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager';
|
||||
export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager' | 'suspendedSshSessions';
|
||||
|
||||
/**
|
||||
* 布局节点接口 (Mirrors frontend definition for backend use)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Client, Channel, ClientChannel } from 'ssh2';
|
||||
|
||||
/**
|
||||
* 后端 SSH 会话状态
|
||||
* 'hanging': 会话正在挂起,SSH 连接活跃。
|
||||
* 'disconnected_by_backend': 后端检测到 SSH 连接意外断开。
|
||||
*/
|
||||
export type BackendSshStatus = 'hanging' | 'disconnected_by_backend';
|
||||
|
||||
/**
|
||||
* 挂起会话的详细信息接口
|
||||
*/
|
||||
export interface SuspendSessionDetails {
|
||||
/** SSH 客户端实例 */
|
||||
sshClient: Client;
|
||||
/** SSH 通道实例 */
|
||||
channel: ClientChannel; // 使用更具体的 ClientChannel 类型
|
||||
/** 临时日志文件路径 */
|
||||
tempLogPath: string;
|
||||
/** 连接名称 */
|
||||
connectionName: string;
|
||||
/** 连接 ID */
|
||||
connectionId: string;
|
||||
/** 挂起开始时间 (ISO 格式字符串) */
|
||||
suspendStartTime: string;
|
||||
/** 用户自定义的挂起会话名称 */
|
||||
customSuspendName?: string;
|
||||
/** 后端 SSH 会话状态 */
|
||||
backendSshStatus: BackendSshStatus;
|
||||
/** 断开连接的时间戳 (ISO 格式字符串),仅当 backendSshStatus 为 'disconnected_by_backend' 时存在 */
|
||||
disconnectionTimestamp?: string;
|
||||
/** 原始会话ID */
|
||||
originalSessionId: string;
|
||||
/** 用户ID */
|
||||
userId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于存储在内存中的挂起会话映射
|
||||
* Key: userId
|
||||
* Value: Map<suspendSessionId, SuspendSessionDetails>
|
||||
*/
|
||||
export type SuspendedSessionsMap = Map<number, Map<string, SuspendSessionDetails>>;
|
||||
|
||||
/**
|
||||
* 用于API响应的挂起会话信息子集
|
||||
*/
|
||||
export interface SuspendedSessionInfo {
|
||||
suspendSessionId: string;
|
||||
connectionName: string;
|
||||
connectionId: string;
|
||||
suspendStartTime: string;
|
||||
customSuspendName?: string;
|
||||
backendSshStatus: BackendSshStatus;
|
||||
disconnectionTimestamp?: string;
|
||||
}
|
||||
@@ -4,11 +4,32 @@ import { RequestHandler } from 'express';
|
||||
import { initializeHeartbeat } from './websocket/heartbeat';
|
||||
import { initializeUpgradeHandler } from './websocket/upgrade';
|
||||
import { initializeConnectionHandler } from './websocket/connection';
|
||||
import { clientStates } from './websocket/state';
|
||||
import { clientStates } from './websocket/state';
|
||||
import { sshSuspendService } from './services/ssh-suspend.service'; // 导入实例
|
||||
// TemporaryLogStorageService 是 SshSuspendService 的依赖,SshSuspendService 内部会处理它的实例化或导入,
|
||||
// websocket.ts 层面不需要直接使用 temporaryLogStorageService。
|
||||
// 如果 SshSuspendService 的构造函数需要一个 TemporaryLogStorageService 实例,
|
||||
// 并且 sshSuspendService 实例是由 ssh-suspend.service.ts 文件创建和导出的,
|
||||
// 那么该文件应该已经处理了 TemporaryLogStorageService 的注入。
|
||||
// 因此,我们只需要导入 sshSuspendService。
|
||||
import {
|
||||
SshSuspendClientToServerMessages,
|
||||
SshSuspendServerToClientMessages,
|
||||
SuspendedSessionInfo
|
||||
} from './websocket/types';
|
||||
import { cleanupClientConnection } from './websocket/utils';
|
||||
|
||||
|
||||
export { ClientState, AuthenticatedWebSocket, DockerContainer, DockerStats, PortInfo } from './websocket/types'; // Re-export essential types
|
||||
export {
|
||||
ClientState,
|
||||
AuthenticatedWebSocket,
|
||||
DockerContainer,
|
||||
DockerStats,
|
||||
PortInfo,
|
||||
SshSuspendClientToServerMessages,
|
||||
SshSuspendServerToClientMessages,
|
||||
SuspendedSessionInfo
|
||||
} from './websocket/types'; // Re-export essential types
|
||||
|
||||
export const initializeWebSocket = async (server: http.Server, sessionParser: RequestHandler): Promise<WebSocketServer> => {
|
||||
// Environment variables are expected to be loaded by index.ts
|
||||
@@ -23,7 +44,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
||||
initializeUpgradeHandler(server, wss, sessionParser);
|
||||
|
||||
// 3. Initialize Connection Handler (handles 'connection' event and message routing)
|
||||
initializeConnectionHandler(wss);
|
||||
initializeConnectionHandler(wss, sshSuspendService); // 传递 sshSuspendService 实例
|
||||
|
||||
// --- WebSocket 服务器关闭处理 ---
|
||||
wss.on('close', () => {
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import WebSocket, { WebSocketServer, RawData } from 'ws';
|
||||
import { Request } from 'express';
|
||||
import { AuthenticatedWebSocket } from './types';
|
||||
import {
|
||||
AuthenticatedWebSocket,
|
||||
SshSuspendStartRequest,
|
||||
SshSuspendListRequest,
|
||||
SshSuspendResumeRequest,
|
||||
SshSuspendTerminateRequest,
|
||||
SshSuspendRemoveEntryRequest,
|
||||
SshSuspendEditNameRequest,
|
||||
SshSuspendStartedResponse,
|
||||
SshSuspendListResponse,
|
||||
SshSuspendResumedNotification,
|
||||
SshOutputCachedChunk,
|
||||
SshSuspendTerminatedResponse,
|
||||
SshSuspendEntryRemovedResponse,
|
||||
SshSuspendNameEditedResponse,
|
||||
SshSuspendAutoTerminatedNotification, // 尽管此消息由服务发起,但类型定义在此处有用
|
||||
ClientState // 导入 ClientState 以便访问 sshClient 等信息
|
||||
} from './types';
|
||||
import { SshSuspendService } from '../services/ssh-suspend.service';
|
||||
import { cleanupClientConnection } from './utils';
|
||||
import { clientStates } from './state'; // Import clientStates for session management
|
||||
|
||||
@@ -23,7 +41,7 @@ import {
|
||||
handleSftpUploadCancel
|
||||
} from './handlers/sftp.handler';
|
||||
|
||||
export function initializeConnectionHandler(wss: WebSocketServer): void {
|
||||
export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendService: SshSuspendService): void {
|
||||
wss.on('connection', (ws: AuthenticatedWebSocket, request: Request) => {
|
||||
ws.isAlive = true;
|
||||
const isRdpProxy = (request as any).isRdpProxy;
|
||||
@@ -107,6 +125,200 @@ export function initializeConnectionHandler(wss: WebSocketServer): void {
|
||||
handleSftpUploadCancel(ws, payload);
|
||||
break;
|
||||
|
||||
// --- SSH Suspend Cases ---
|
||||
case 'SSH_SUSPEND_START': {
|
||||
const { sessionId: originalFrontendSessionId } = payload as SshSuspendStartRequest['payload'];
|
||||
console.log(`[WebSocket Handler] Received SSH_SUSPEND_START. UserID: ${ws.userId}, WsSessionID: ${ws.sessionId}, TargetOriginalFrontendSessionID: ${originalFrontendSessionId}`);
|
||||
console.log(`[SSH_SUSPEND_START] (Debug) 当前 clientStates 中的 keys: ${JSON.stringify(Array.from(clientStates.keys()))}`);
|
||||
// console.log(`[SSH_SUSPEND_START] 当前 WebSocket (ws.sessionId): ${ws.sessionId}`); // 重复,已包含在上一条日志
|
||||
|
||||
if (!ws.userId) {
|
||||
console.error(`[SSH_SUSPEND_START] 用户 ID 未定义。`);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_STARTED_RESP', payload: { frontendSessionId: originalFrontendSessionId, suspendSessionId: '', success: false, error: '用户认证失败' } }));
|
||||
break;
|
||||
}
|
||||
const activeSessionState = clientStates.get(originalFrontendSessionId);
|
||||
if (!activeSessionState || !activeSessionState.sshClient || !activeSessionState.sshShellStream) {
|
||||
console.error(`[SSH_SUSPEND_START] 找不到活动的SSH会话或其组件: ${originalFrontendSessionId}`);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_STARTED_RESP', payload: { frontendSessionId: originalFrontendSessionId, suspendSessionId: '', success: false, error: '未找到活动的SSH会话' } }));
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const suspendSessionId = await sshSuspendService.startSuspend(
|
||||
ws.userId,
|
||||
originalFrontendSessionId,
|
||||
activeSessionState.sshClient,
|
||||
activeSessionState.sshShellStream,
|
||||
activeSessionState.connectionName || '未知连接',
|
||||
String(activeSessionState.dbConnectionId), // 确保是 string
|
||||
// customSuspendName 初始时可以为空或基于 connectionName
|
||||
);
|
||||
const response: SshSuspendStartedResponse = {
|
||||
type: 'SSH_SUSPEND_STARTED',
|
||||
payload: { frontendSessionId: originalFrontendSessionId, suspendSessionId, success: true }
|
||||
};
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response));
|
||||
// 设计文档提到:“原有的直接将 SSH 输出发送到前端 WebSocket 的逻辑需要暂停或修改”
|
||||
// 这部分可能需要修改 ssh.handler.ts,或者 SshSuspendService 内部通过移除监听器等方式实现。
|
||||
// SshSuspendService.startSuspend 内部应该已经处理了数据流重定向到日志。
|
||||
// clientStates.delete(originalFrontendSessionId); // 原会话不再由 websocket 直接管理,转由 SshSuspendService 管理
|
||||
} catch (error: any) {
|
||||
console.error(`[SSH_SUSPEND_START] 启动挂起失败:`, error);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_STARTED_RESP', payload: { frontendSessionId: originalFrontendSessionId, suspendSessionId: '', success: false, error: error.message || '启动挂起失败' } }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'SSH_SUSPEND_LIST_REQUEST': {
|
||||
if (!ws.userId) {
|
||||
console.error(`[SSH_SUSPEND_LIST_REQUEST] 用户 ID 未定义。`);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_LIST_RESPONSE', payload: { suspendSessions: [] } })); // 返回空列表或错误
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const sessions = await sshSuspendService.listSuspendedSessions(ws.userId);
|
||||
const response: SshSuspendListResponse = {
|
||||
type: 'SSH_SUSPEND_LIST_RESPONSE',
|
||||
payload: { suspendSessions: sessions }
|
||||
};
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response));
|
||||
} catch (error: any) {
|
||||
console.error(`[SSH_SUSPEND_LIST_REQUEST] 获取挂起列表失败:`, error);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_LIST_RESPONSE', payload: { suspendSessions: [] } })); // 返回空列表或错误
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'SSH_SUSPEND_RESUME_REQUEST': {
|
||||
const { suspendSessionId, newFrontendSessionId } = payload as SshSuspendResumeRequest['payload'];
|
||||
console.log(`[WebSocket Handler] Received SSH_SUSPEND_RESUME_REQUEST. UserID: ${ws.userId}, WsSessionID: ${ws.sessionId}, SuspendSessionID: ${suspendSessionId}, NewFrontendSessionID: ${newFrontendSessionId}`);
|
||||
if (!ws.userId) {
|
||||
console.error(`[SSH_SUSPEND_RESUME_REQUEST] 用户 ID 未定义。Payload: ${JSON.stringify(payload)}`);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_RESUMED_NOTIF', payload: { suspendSessionId, newFrontendSessionId, success: false, error: '用户认证失败' } }));
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const result = await sshSuspendService.resumeSession(ws.userId, suspendSessionId);
|
||||
if (result) {
|
||||
// 将恢复的 sshClient 和 channel 重新关联到新的前端会话 ID
|
||||
// 这部分逻辑需要与 handleSshConnect 类似,创建一个新的 ClientState
|
||||
const newSessionState: ClientState = {
|
||||
ws, // 当前的 WebSocket 连接
|
||||
sshClient: result.sshClient,
|
||||
sshShellStream: result.channel,
|
||||
dbConnectionId: parseInt(result.originalConnectionId, 10), // 从结果中恢复并转换为数字
|
||||
connectionName: result.connectionName, // 从结果中恢复
|
||||
ipAddress: clientIp,
|
||||
isShellReady: true, // 假设恢复后 Shell 立即可用
|
||||
};
|
||||
clientStates.set(newFrontendSessionId, newSessionState);
|
||||
ws.sessionId = newFrontendSessionId; // 将当前 ws 与新会话关联
|
||||
|
||||
// 重新设置事件监听器,将数据流导向新的前端会话
|
||||
result.channel.removeAllListeners('data'); // 清除 SshSuspendService 可能设置的监听器
|
||||
result.channel.on('data', (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ssh:output', payload: { sessionId: newFrontendSessionId, data: data.toString('utf-8') } }));
|
||||
}
|
||||
});
|
||||
result.channel.on('close', () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: { sessionId: newFrontendSessionId } }));
|
||||
}
|
||||
cleanupClientConnection(newFrontendSessionId);
|
||||
});
|
||||
result.sshClient.on('error', (err: Error) => {
|
||||
console.error(`恢复后的 SSH 客户端错误 (会话: ${newFrontendSessionId}):`, err);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ssh:error', payload: { sessionId: newFrontendSessionId, error: err.message } }));
|
||||
cleanupClientConnection(newFrontendSessionId);
|
||||
});
|
||||
|
||||
// 发送缓存日志块
|
||||
// 设计文档建议 SSH_OUTPUT_CACHED_CHUNK
|
||||
// 这个服务返回的是一个完整的 logData 字符串,我们需要分块吗?
|
||||
// 假设暂时不分块,或者由前端处理。如果需要分块,逻辑会更复杂。
|
||||
// 这里简单处理,一次性发送。如果日志过大,这可能不是最佳实践。
|
||||
const logChunkResponse: SshOutputCachedChunk = {
|
||||
type: 'SSH_OUTPUT_CACHED_CHUNK',
|
||||
payload: { frontendSessionId: newFrontendSessionId, data: result.logData, isLastChunk: true }
|
||||
};
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(logChunkResponse));
|
||||
|
||||
const response: SshSuspendResumedNotification = {
|
||||
type: 'SSH_SUSPEND_RESUMED',
|
||||
payload: { suspendSessionId, newFrontendSessionId, success: true }
|
||||
};
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response));
|
||||
|
||||
} else {
|
||||
throw new Error('无法恢复会话,或会话不存在/状态不正确。');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[SSH_SUSPEND_RESUME_REQUEST] 恢复会话 ${suspendSessionId} 失败:`, error);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_RESUMED_NOTIF', payload: { suspendSessionId, newFrontendSessionId, success: false, error: error.message || '恢复会话失败' } }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'SSH_SUSPEND_TERMINATE_REQUEST': {
|
||||
const { suspendSessionId } = payload as SshSuspendTerminateRequest['payload'];
|
||||
console.log(`[WebSocket Handler] Received SSH_SUSPEND_TERMINATE_REQUEST. UserID: ${ws.userId}, WsSessionID: ${ws.sessionId}, SuspendSessionID: ${suspendSessionId}`);
|
||||
if (!ws.userId) {
|
||||
console.error(`[SSH_SUSPEND_TERMINATE_REQUEST] 用户 ID 未定义。Payload: ${JSON.stringify(payload)}`);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_TERMINATED_RESP', payload: { suspendSessionId, success: false, error: '用户认证失败' } }));
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const success = await sshSuspendService.terminateSuspendedSession(ws.userId, suspendSessionId);
|
||||
const response: SshSuspendTerminatedResponse = {
|
||||
type: 'SSH_SUSPEND_TERMINATED',
|
||||
payload: { suspendSessionId, success }
|
||||
};
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response));
|
||||
} catch (error: any) {
|
||||
console.error(`[SSH_SUSPEND_TERMINATE_REQUEST] 终止会话 ${suspendSessionId} 失败:`, error);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_TERMINATED_RESP', payload: { suspendSessionId, success: false, error: error.message || '终止会话失败' } }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'SSH_SUSPEND_REMOVE_ENTRY': {
|
||||
const { suspendSessionId } = payload as SshSuspendRemoveEntryRequest['payload'];
|
||||
console.log(`[WebSocket Handler] Received SSH_SUSPEND_REMOVE_ENTRY. UserID: ${ws.userId}, WsSessionID: ${ws.sessionId}, SuspendSessionID: ${suspendSessionId}`);
|
||||
if (!ws.userId) {
|
||||
console.error(`[SSH_SUSPEND_REMOVE_ENTRY] 用户 ID 未定义。Payload: ${JSON.stringify(payload)}`);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_ENTRY_REMOVED_RESP', payload: { suspendSessionId, success: false, error: '用户认证失败' } }));
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const success = await sshSuspendService.removeDisconnectedSessionEntry(ws.userId, suspendSessionId);
|
||||
const response: SshSuspendEntryRemovedResponse = {
|
||||
type: 'SSH_SUSPEND_ENTRY_REMOVED',
|
||||
payload: { suspendSessionId, success }
|
||||
};
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response));
|
||||
} catch (error: any) {
|
||||
console.error(`[SSH_SUSPEND_REMOVE_ENTRY] 移除条目 ${suspendSessionId} 失败:`, error);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_ENTRY_REMOVED_RESP', payload: { suspendSessionId, success: false, error: error.message || '移除条目失败' } }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'SSH_SUSPEND_EDIT_NAME': {
|
||||
const { suspendSessionId, customName } = payload as SshSuspendEditNameRequest['payload'];
|
||||
if (!ws.userId) {
|
||||
console.error(`[SSH_SUSPEND_EDIT_NAME] 用户 ID 未定义。`);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_NAME_EDITED_RESP', payload: { suspendSessionId, success: false, error: '用户认证失败' } }));
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const success = await sshSuspendService.editSuspendedSessionName(ws.userId, suspendSessionId, customName);
|
||||
const response: SshSuspendNameEditedResponse = {
|
||||
type: 'SSH_SUSPEND_NAME_EDITED',
|
||||
payload: { suspendSessionId, success, customName: success ? customName : undefined }
|
||||
};
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(response));
|
||||
} catch (error: any) {
|
||||
console.error(`[SSH_SUSPEND_EDIT_NAME] 编辑名称 ${suspendSessionId} 失败:`, error);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'SSH_SUSPEND_NAME_EDITED_RESP', payload: { suspendSessionId, success: false, error: error.message || '编辑名称失败' } }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn(`WebSocket:收到来自 ${ws.username} (会话: ${sessionId}) 的未知消息类型: ${type}`);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'error', payload: `不支持的消息类型: ${type}` }));
|
||||
@@ -128,5 +340,27 @@ export function initializeConnectionHandler(wss: WebSocketServer): void {
|
||||
});
|
||||
}
|
||||
});
|
||||
console.log('WebSocket connection handler initialized.');
|
||||
|
||||
// 监听 SshSuspendService 发出的会话自动终止事件
|
||||
sshSuspendService.on('sessionAutoTerminated', (eventPayload: { userId: number; suspendSessionId: string; reason: string }) => {
|
||||
const { userId, suspendSessionId, reason } = eventPayload;
|
||||
console.log(`[WebSocket 通知] 准备发送 SSH_SUSPEND_AUTO_TERMINATED_NOTIF 给用户 ${userId} 的会话 ${suspendSessionId}`);
|
||||
|
||||
wss.clients.forEach(client => {
|
||||
const wsClient = client as AuthenticatedWebSocket; // 类型断言
|
||||
if (wsClient.userId === userId && wsClient.readyState === WebSocket.OPEN) {
|
||||
const notification: SshSuspendAutoTerminatedNotification = {
|
||||
type: 'SSH_SUSPEND_AUTO_TERMINATED',
|
||||
payload: {
|
||||
suspendSessionId,
|
||||
reason
|
||||
}
|
||||
};
|
||||
wsClient.send(JSON.stringify(notification));
|
||||
console.log(`[WebSocket 通知] 已发送 SSH_SUSPEND_AUTO_TERMINATED_NOTIF 给用户 ${userId} 的一个 WebSocket 连接 (会话 ${suspendSessionId})。`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('WebSocket connection handler initialized, including SshSuspendService event listener.');
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface ClientState { // 导出以便 Service 可以导入
|
||||
dockerStatusIntervalId?: NodeJS.Timeout; // NEW: Docker 状态轮询 ID
|
||||
ipAddress?: string; // 添加 IP 地址字段
|
||||
isShellReady?: boolean; // 新增:标记 Shell 是否已准备好处理输入和调整大小
|
||||
isSuspendedByService?: boolean; // 新增:标记此会话是否已被 SshSuspendService 接管
|
||||
}
|
||||
|
||||
export interface PortInfo {
|
||||
@@ -56,4 +57,157 @@ export interface DockerContainer {
|
||||
Ports: PortInfo[];
|
||||
Labels: Record<string, string>;
|
||||
stats?: DockerStats | null; // 可选的 stats 字段
|
||||
}
|
||||
}
|
||||
// --- SSH Suspend Mode WebSocket Message Types ---
|
||||
|
||||
// Client -> Server
|
||||
export interface SshSuspendStartRequest {
|
||||
type: "SSH_SUSPEND_START";
|
||||
payload: {
|
||||
sessionId: string; // The ID of the active SSH session to be suspended
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshSuspendListRequest {
|
||||
type: "SSH_SUSPEND_LIST_REQUEST";
|
||||
}
|
||||
|
||||
export interface SshSuspendResumeRequest {
|
||||
type: "SSH_SUSPEND_RESUME_REQUEST";
|
||||
payload: {
|
||||
suspendSessionId: string; // The ID of the suspended session to resume
|
||||
newFrontendSessionId: string; // The new frontend session ID for the resumed connection
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshSuspendTerminateRequest {
|
||||
type: "SSH_SUSPEND_TERMINATE_REQUEST";
|
||||
payload: {
|
||||
suspendSessionId: string; // The ID of the active suspended session to terminate
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshSuspendRemoveEntryRequest {
|
||||
type: "SSH_SUSPEND_REMOVE_ENTRY";
|
||||
payload: {
|
||||
suspendSessionId: string; // The ID of the disconnected session entry to remove
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshSuspendEditNameRequest {
|
||||
type: "SSH_SUSPEND_EDIT_NAME";
|
||||
payload: {
|
||||
suspendSessionId: string;
|
||||
customName: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Server -> Client
|
||||
export interface SshSuspendStartedResponse {
|
||||
type: "SSH_SUSPEND_STARTED";
|
||||
payload: {
|
||||
frontendSessionId: string; // The original frontend session ID
|
||||
suspendSessionId: string; // The new ID for the suspended session
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SuspendedSessionInfo {
|
||||
suspendSessionId: string;
|
||||
connectionName: string; // Original connection name
|
||||
connectionId: string; // Original connection ID
|
||||
suspendStartTime: string; // ISO string
|
||||
customSuspendName?: string;
|
||||
backendSshStatus: 'hanging' | 'disconnected_by_backend';
|
||||
disconnectionTimestamp?: string; // ISO string, if applicable
|
||||
}
|
||||
|
||||
export interface SshSuspendListResponse {
|
||||
type: "SSH_SUSPEND_LIST_RESPONSE";
|
||||
payload: {
|
||||
suspendSessions: SuspendedSessionInfo[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshSuspendResumedNotification {
|
||||
type: "SSH_SUSPEND_RESUMED";
|
||||
payload: {
|
||||
suspendSessionId: string;
|
||||
newFrontendSessionId: string; // The frontend session ID this resumed session is now associated with
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshOutputCachedChunk {
|
||||
type: "SSH_OUTPUT_CACHED_CHUNK";
|
||||
payload: {
|
||||
frontendSessionId: string; // The frontend session ID to send the chunk to
|
||||
data: string;
|
||||
isLastChunk: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshSuspendTerminatedResponse {
|
||||
type: "SSH_SUSPEND_TERMINATED";
|
||||
payload: {
|
||||
suspendSessionId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshSuspendEntryRemovedResponse {
|
||||
type: "SSH_SUSPEND_ENTRY_REMOVED";
|
||||
payload: {
|
||||
suspendSessionId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshSuspendNameEditedResponse {
|
||||
type: "SSH_SUSPEND_NAME_EDITED";
|
||||
payload: {
|
||||
suspendSessionId: string;
|
||||
success: boolean;
|
||||
customName?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshSuspendAutoTerminatedNotification {
|
||||
type: "SSH_SUSPEND_AUTO_TERMINATED";
|
||||
payload: {
|
||||
suspendSessionId: string;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Union type for all client-to-server messages for SSH Suspend
|
||||
export type SshSuspendClientToServerMessages =
|
||||
| SshSuspendStartRequest
|
||||
| SshSuspendListRequest
|
||||
| SshSuspendResumeRequest
|
||||
| SshSuspendTerminateRequest
|
||||
| SshSuspendRemoveEntryRequest
|
||||
| SshSuspendEditNameRequest;
|
||||
|
||||
// Union type for all server-to-client messages for SSH Suspend
|
||||
export type SshSuspendServerToClientMessages =
|
||||
| SshSuspendStartedResponse
|
||||
| SshSuspendListResponse
|
||||
| SshSuspendResumedNotification
|
||||
| SshOutputCachedChunk
|
||||
| SshSuspendTerminatedResponse
|
||||
| SshSuspendEntryRemovedResponse
|
||||
| SshSuspendNameEditedResponse
|
||||
| SshSuspendAutoTerminatedNotification;
|
||||
|
||||
// It might be useful to have a general type for incoming messages if not already present
|
||||
// For example, if you have a main message handler:
|
||||
// export type WebSocketMessage = BaseMessageType | SshSuspendClientToServerMessages | OtherFeatureMessages;
|
||||
// And for outgoing:
|
||||
// export type WebSocketResponse = BaseResponseType | SshSuspendServerToClientMessages | OtherFeatureResponses;
|
||||
// This part depends on the existing structure, so I'm providing the specific types for now.
|
||||
@@ -80,8 +80,15 @@ export const cleanupClientConnection = (sessionId: string | undefined) => {
|
||||
sftpService.cleanupSftpSession(sessionId);
|
||||
|
||||
// 3. 清理 SSH 连接
|
||||
state.sshShellStream?.end(); // 结束 shell 流
|
||||
state.sshClient?.end(); // 结束 SSH 客户端
|
||||
// +++ 仅当会话未被 SshSuspendService 接管时才关闭 SSH 连接 +++
|
||||
if (!state.isSuspendedByService) {
|
||||
state.sshShellStream?.end(); // 结束 shell 流
|
||||
state.sshClient?.end(); // 结束 SSH 客户端
|
||||
console.log(`WebSocket: 会话 ${sessionId} 的 SSH 连接已关闭 (未被挂起服务接管)。`);
|
||||
} else {
|
||||
console.log(`WebSocket: 会话 ${sessionId} 的 SSH 连接由挂起服务管理,跳过关闭。`);
|
||||
}
|
||||
// +++ 结束条件关闭 +++
|
||||
|
||||
// 4. 清理 Docker 状态轮询定时器
|
||||
if (state.dockerStatusIntervalId) {
|
||||
|
||||
Reference in New Issue
Block a user