VUE3 + xterm + nestjs实现web远程终端 或 连接开启SSH登录的路由器和交换机。

可远程连接系统终端或开启SSH登录的路由器和交换机。
相关资料:
xtermjs/xterm.js: A terminal for the web (github.com)

后端实现(NestJS):

1、安装依赖:
npm install node-ssh @nestjs/websockets @nestjs/platform-socket.io
2、我们将创建一个名为 RemoteControlModule 的 NestJS 模块,该模块将包括 SSH 服务、WebSocket 网关和必要的配置,运行cli:
nest g module remoteControl
nest g service remoteControl
nest g gateway remoteControl --no-spec
3、remote-control.module.ts 模块
import { Module } from '@nestjs/common';
import { RemoteControlService } from './remote-control.service';
import { RemoteControlGateway } from './remote-control.gateway';

@Module({
  providers: [RemoteControlService, RemoteControlGateway],
})
export class RemoteControlModule {}
4、remote-control.service.ts SSH服务:
import { Injectable } from '@nestjs/common'; // 导入 NestJS 的 Injectable 装饰器,用于定义依赖注入的服务
import { NodeSSH } from 'node-ssh'; // 导入 node-ssh 库,用于实现 SSH 连接和命令执行
import { Duplex } from 'stream'; // 引入 Duplex 类型

@Injectable() // 使用 @Injectable 装饰器标记该类为可注入的服务
export class RemoteControlService {
  // 定义一个私有属性 sshSessions,它是一个 Map 类型的集合,用于存储 SSH 会话信息。
  // Map 的键是一个字符串,代表客户端的唯一标识符。
  // Map 的值是一个对象,包含以下属性:
  // ssh: NodeSSH 类型,表示一个 SSH 连接实例,用于执行 SSH 命令。
  // currentDirectory: 字符串类型,表示当前工作目录的路径。
  // homeDirectory: 字符串类型,表示用户的家目录路径。
  // shellStream: 可选的 Duplex 流类型,表示一个双向流,用于与 SSH 会话进行交互。
  private sshSessions = new Map<
    string,
    {
      ssh: NodeSSH;
      currentDirectory: string;
      homeDirectory: string;
      shellStream?: Duplex;
    }
  >();

  // 定义一个设备类型的字段,用于区分不同类型的设备
  private deviceType = 'linux'; // 默认设备类型为 Linux

  // 初始化会话
  initializeSession(clientId: string) {
    try {
      // 检查是否已存在会话,避免重复初始化
      if (this.sshSessions.has(clientId)) {
        console.log(`会话已存在: ${clientId}`);
        return;
      }

      // 创建新的会话状态
      this.sshSessions.set(clientId, {
        ssh: new NodeSSH(), // 创建一个新的 NodeSSH 实例
        currentDirectory: '/', // 默认当前目录为根目录
        homeDirectory: '/', // 默认家目录为根目录
        shellStream: undefined, // 初始时没有 shellStream
      });

      console.log(`会话初始化完成: ${clientId}`);
    } catch (error) {
      console.log('初始化会话时发生错误:', error);
    }
  }

  // 定义一个异步方法 startSSHSession,用于启动一个 SSH 会话
  async startSSHSession(
    host: string, // 主机地址
    username: string, // 用户名
    password: string, // 密码
    clientId: string, // 客户端标识符
    type: string, // 接收设备类型参数
  ): Promise {
    // 设置设备类型
    this.deviceType = type;

    // 检查会话是否已经初始化
    const session = this.sshSessions.get(clientId);
    if (!session) {
      // 断开连接
      this.disconnect(clientId);
      return '会话未初始化, 请先初始化会话';
    }
    try {
      // 连接到 SSH 服务器
      await session.ssh.connect({
        host, // 服务器地址
        username, // 用户名
        password, // 密码
        port: 3122, // 指定端口为3122
        // 需要了解服务器支持哪些密钥交换算法。这可以通过使用 SSH 命令行工具(如 ssh)与 -Q kex 选项来查看,或者联系你的服务器管理员获取这些信息。
        algorithms: {
          kex: [
            'ecdh-sha2-nistp256',
            'ecdh-sha2-nistp384',
            'ecdh-sha2-nistp521',
            'diffie-hellman-group-exchange-sha256',
            'diffie-hellman-group14-sha1',
            'diffie-hellman-group1-sha1', // 这是一个较旧的算法,安全性较低,最后尝试
          ],
          cipher: [
            'aes128-ctr',
            'aes192-ctr',
            'aes256-ctr',
            '[email protected]',
            '[email protected]',
            'aes128-cbc',
            'aes192-cbc',
            'aes256-cbc', // CBC 模式的算法安全性较低,建议谨慎使用
          ],
          hmac: [
            'hmac-sha2-256',
            'hmac-sha2-512',
            'hmac-sha1', // SHA1 的 HMAC 也是较旧的选择
          ],
        },
      });
      // 请求一个 shell 流,可以指定终端类型。终端类型决定了终端的行为和功能,比如字符编码、颜色支持等。
      // 常见的终端类型包括 'xterm', 'vt100', 'ansi' 等。
      // 'xterm' 是最常用的终端类型,支持颜色和鼠标事件。
      // 'vt100' 是较旧的终端类型,功能较为基础。
      // 'ansi' 也是一种常见的终端类型,支持 ANSI 转义序列。
      const shellStream = await session.ssh.requestShell({
        term: 'xterm',
      });
      // 更新会话信息
      session.shellStream = shellStream; // shell 流

      // 如果是 Linux 终端
      if (this.deviceType == 'linux') {
        // 执行命令获取用户的家目录,并去除两端的空白字符
        const homeDirectory = (
          await session.ssh.execCommand('echo $HOME')
        ).stdout.trim();
        session.currentDirectory = homeDirectory; // 当前目录设置为家目录
        session.homeDirectory = homeDirectory; // 家目录
      } else {
        // 如果是路由器或交换机,执行其他初始化设置
        // 例如:session.currentDirectory = '/';
      }

      // 返回一个字符串,表示 SSH 会话已经启动
      return `SSH 会话成功启动, 主机: ${host}, 用户: ${username}`;
    } catch (error) {
      // 如果设备类型是路由器交换机,发送退出命令
      if (this.deviceType === 'device') {
        this.sendExitCommands(clientId);
      } else {
        this.disconnect(clientId);
      }
      return 'SSH 会话启动失败,失败原因:' + error.message;
    }
  }

  /**
   * 根据客户端标识符获取对应的 SSH 会话的 shell 流。
   * 如果会话不存在或 shell 流未初始化,则返回 undefined。
   * @param clientId 客户端标识符
   * @returns 返回对应的 Duplex 流,如果会话不存在或 shell 流未初始化则返回 undefined。
   */
  getShellStream(clientId: string): Duplex | undefined {
    try {
      const session = this.sshSessions.get(clientId);
      if (!session) {
        console.error(`未找到客户端ID为 ${clientId} 的会话`);
        return undefined;
      }
      if (!session.shellStream) {
        console.error(`客户端ID为 ${clientId} 的会话中未初始化 shell 流`);
        return undefined;
      }
      return session.shellStream;
    } catch (error) {
      console.log('获取 shell 流时发生错误:', error);
      return undefined;
    }
  }

  async sendExitCommands(clientId: string): Promise {
    const session = this.sshSessions.get(clientId);
    if (!session) {
      return '会话不存在';
    }
    if (!session.ssh.isConnected()) {
      return 'SSH连接已关闭';
    }
    if (session.shellStream && session.shellStream.writable) {
      try {
        // 监听错误事件
        session.shellStream.on('error', (error) => {
          console.error('Shell流错误:', error);
          this.cleanupSession(clientId);
        });
        // 监听关闭事件
        session.shellStream.on('close', () => {
          console.log('Shell 流已关闭');
          // 移除所有监听器
          session.shellStream.removeAllListeners();
          this.cleanupSession(clientId);
        });

        session.shellStream.on('data', (data) => {
          console.log('从路由器接收到的数据:', data.toString());
        });
        console.log('-----发送退出命令-----');
        // 发送退出命令
        // await session.ssh.execCommand('\x1A'); // Ctrl+Z
        // await session.ssh.execCommand('quit');
        session.shellStream.write('\x1A');
        // 等待一段时间以确保命令被处理,执行quit命令会导致Shell流关闭,从而触发 close 事件
        session.shellStream.write('quit\n');
        // 确保命令发送完成
        await new Promise((resolve) => setTimeout(resolve, 500));
        session.shellStream.end(); // 关闭写入流
        console.log('-----退出命令已发送-----');
        return '退出命令已发送';
      } catch (error) {
        console.error('设备执行退出命令时发生错误:', error);
        return '设备执行退出命令时发生错误';
      }
    } else {
      // 如果Shell流不可写或不存在,则清理会话
      this.cleanupSession(clientId);
      return 'Shell流不可写或不存在';
    }
  }

  // 只释放ssh连接,不释放shell流
  async cleanupSession(clientId: string): Promise {
    const session = this.sshSessions.get(clientId);
    if (session && session.ssh.isConnected()) {
      try {
        session.ssh.dispose();
      } catch (error) {
        console.error('释放SSH连接时发生错误:', error);
      }
    }
    this.sshSessions.delete(clientId);
    console.log('cleanupSession SSH会话和资源已成功释放');
  }

  // 释放ssh连接和shell流
  async disconnect(clientId: string): Promise {
    console.log(`设备断开: ${clientId}`);
    const session = this.sshSessions.get(clientId);
    if (!session) {
      return '会话不存在';
    }
    try {
      // 关闭 shell 流并清除监听器
      if (session.shellStream) {
        //监听流结束事件
        session.shellStream.end();
        session.shellStream.removeAllListeners();
      }
      // 释放 SSH 连接
      if (session.ssh.isConnected()) {
        try {
          session.ssh.dispose();
        } catch (disposeError) {
          console.error('释放 SSH 连接时发生错误:', disposeError);
        }
      }
      // 从映射中删除会话
      this.sshSessions.delete(clientId);
      console.log('disconnect SSH会话和资源已成功释放');
      return 'SSH会话和资源已成功释放';
    } catch (error) {
      console.error('断开连接时发生错误:', error);
      return '断开连接时发生错误';
    }
  }
}

5、remote-control.gateway.ts WebSocket网关:

import {
  WebSocketGateway, // 导入WebSocketGateway装饰器,用于定义WebSocket网关
  WebSocketServer, // 导入WebSocketServer装饰器,用于注入WebSocket服务器实例
  SubscribeMessage, // 导入SubscribeMessage装饰器,用于定义处理WebSocket消息的方法
  ConnectedSocket, // 导入ConnectedSocket装饰器,用于获取连接的WebSocket客户端
  MessageBody,
} from '@nestjs/websockets'; // 从NestJS的WebSocket模块中导入装饰器
import { Socket, Server } from 'socket.io'; // 导入Socket.IO的Socket类型
// 导入远程控制服务
import { RemoteControlService } from './remote-control.service';

// 使用WebSocketGateway装饰器定义一个WebSocket网关,监听8113端口
@WebSocketGateway(8113, {
  // 允许跨域
  cors: {
    origin: '*', // 允许所有来源
  },
  // 定义命名空间
  namespace: 'control', // 默认是 /,如果设置成 /control,那么客户端连接的时候,就需要使用 ws://localhost:8113/control 这种形式
})
export class RemoteControlGateway {
  // 注入WebSocket服务器实例,需要向所有客户端广播消息时使用
  @WebSocketServer() server: Server;

  // 定义一个设备类型的字段,用于区分不同类型的设备
  private deviceType = 'linux'; // 默认设备类型为 Linux

  // 构造函数,注入远程控制服务
  constructor(private remoteControlService: RemoteControlService) {}

  // 当客户端连接时触发
  handleConnection(client: Socket) {
    console.log(`客户端接入: ${client.id}`);
    // 初始化 SSH 会话
    this.remoteControlService.initializeSession(client.id);
  }

  // 当客户端断开连接时触发
  handleDisconnect(client: Socket) {
    console.log(`客户端断开: ${client.id}`);
  }

  // 处理启动终端会话的请求,传入主机地址、用户名和密码、设备类型
  @SubscribeMessage('startTerminalSession')
  async handleStartTerminalSession(
    @MessageBody()
    data: { host: string; username: string; password: string; type: string },
    @ConnectedSocket() client: Socket,
  ) {
    const clientId = client.id;
    this.deviceType = data.type;
    try {
      // 启动 SSH 会话
      const message = await this.remoteControlService.startSSHSession(
        data.host,
        data.username,
        data.password,
        clientId,
        data.type, // 传递设备类型到服务层
      );
      // 获取 SSH 会话的 shell 流
      const shellStream = this.remoteControlService.getShellStream(clientId);
      if (shellStream) {
        // 监听 shell 流的 data 事件,当主机SSH会话有输出时触发
        shellStream.on('data', (data: Buffer) => {
          // 确保发送的是字符串格式的数据
          client.emit('terminalData', data.toString('utf8'));
        });
      }
      // 发送启动终端会话的成功消息
      client.emit('terminalSessionStarted', { message, clientId });
    } catch (error) {
      // 发送启动终端会话的失败消息
      client.emit('terminalSessionError', error.message);
      // 如果设备类型是路由器交换机,发送退出命令
      if (this.deviceType === 'device') {
        await this.remoteControlService.sendExitCommands(clientId);
      } else {
        // 执行断开设备连接
        this.remoteControlService.disconnect(clientId);
      }
    }
  }

  // 处理终端输入,传入客户端ID和输入的命令
  @SubscribeMessage('input')
  async handleInput(
    @MessageBody() data: { clientId: string; input: string },
    @ConnectedSocket() client: Socket,
  ) {
    if (client.disconnected) {
      console.log(`客户端 ${client.id} 已断开连接,停止处理输入`);
      return;
    }
    try {
      // 根据客户端 ID 获取 SSH 会话的 shell 流
      const shellStream = this.remoteControlService.getShellStream(
        data.clientId,
      );
      // 如果 shell 流存在且可写,将输入写入 shell 流
      if (shellStream && shellStream.writable) {
        // 检查输入是否为退格键,并发送正确的字符
        shellStream.write(data.input);
      } else {
        // 如果 shell 流不存在或不可写,发送错误消息
        client.emit(
          'terminalSessionError',
          'Shell终端不可用,请检查是否已启动终端会话.',
        );
        // 如果设备类型是路由器交换机,发送退出命令
        if (this.deviceType === 'device') {
          await this.remoteControlService.sendExitCommands(data.clientId);
        } else {
          // 执行断开设备连接
          this.remoteControlService.disconnect(data.clientId);
        }
      }
    } catch (error) {
      console.log('处理终端输入时发生错误:', error);
    }
  }

  // 处理断开终端连接的请求,传入客户端ID
  @SubscribeMessage('disconnectTerminal')
  async handleDisconnect1(
    @MessageBody() clientId: string,
    @ConnectedSocket() client: Socket,
  ) {
    console.log('进入 sendExitCommands 断开设备的方法:', clientId);
    // 如果设备类型是路由器交换机,发送退出命令
    if (this.deviceType == 'device') {
      client.emit('terminalDisconnected', '设备终端已断开');
      const message = await this.remoteControlService.sendExitCommands(
        clientId,
      );
      console.log('执行 sendExitCommands 设备命令之后:', message);
    } else {
      client.emit('terminalDisconnected', '系统终端已断开');
      // 执行断开设备连接
      this.remoteControlService.disconnect(clientId);
    }
  }
}

6、main.ts 未捕获异常进行捕获(如果连接的是路由器终端就需要这个配置):

import { NestFactory} from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';

async function bootstrap() {
  // 使用NestFactory.create()方法创建一个Nest应用实例
  const app = await NestFactory.create(AppModule);
  // 启用 CORS 跨域
  app.enableCors();
  // 为所有路由设置前缀
  app.setGlobalPrefix('api');

  // 在程序的入口点或适当的位置添加全局未捕获异常的监听器
  process.on('uncaughtException', (error) => {
    console.error('未捕获的异常:', error);
  });

  // 未处理的 Promise 拒绝的监听
  process.on('unhandledRejection', (reason, promise) => {
    console.error('未处理的拒绝:', promise, '原因:', reason);
  });

  // 使用app.listen()方法启动应用
  await app.listen(3000);
}
bootstrap();

前端实现(VUE3+xterm.js):

1、安装依赖:
npm install @xterm/xterm @xterm/addon-fit @xterm/addon-attach socket.io-client
2、xterm终端实现:





你可能感兴趣的:(node.js,vue)