如何搭建一个简易的 Web Terminal(二)

1,136 阅读11分钟

前言

笔者在上一次关于 Web Terminal 的分享中遗留了一些带改善的点,首当其冲的自然就是需要接入 Websocket 和 SSH。鉴于此,笔者带来了 Web Terminal 的第二篇分享,在本篇文章中解决遗留下来的问题。

首先,第一个需要处理的就是需要在 Doreamon 中接入 Websocket。在接入 Websocket 的过程中,笔者发现像 Doreamon 这样的,使用 Egg 的框架,并且又使用 React 和 Ts 的项目比较的少,与之对应的就是接入 Websocket 的一些相关的文章呢,也比较的少,或者说描述的语焉不详,可有可无。是以,本篇文章会详细的介绍笔者是如何在 Doreamon 接入的 Websocket,当然其中也有一些笔者的思考和遇到的问题。

其次,在本文的第二部分,笔者接入了 SSH,执行基本的相关的命令,以此来达到能够执行基本的一些 Shell 命令。本文在介绍如何接入 SSH 的同时也记录了一些问题,因为关于接入的 SSH 过程比较简易一些,本篇文章对于此的描述篇幅所占比例少一些。

最后,希望本篇文章可以在其他同学在类似的场景需要使用 Websocket 或 SSH 的时候答疑解惑,同时,有不对的地方欢迎大家指正。

在 Doreamon 中接入 socket.io

首先,由于 Doreamon 本身使用的是 egg 的框架,笔者根据官方的推荐,使用 socket.io 接入Websocket。有意思的是,官方对于使用 socket.io 给出了一下一句话:

socket.io 支持 websocket、polling 两种数据传输方式以兼容浏览器不支持 WebSocket 场景下的通信需求。

这句话也可以引出后面的一个问题,稍后笔者再描述说明。在开始实践之前,笔者先简单介绍一下 socket.io 的基本使用

socket.io 的基本使用

1、什么是 socket.io ?

官方的解释如下:

Socket.IO 是一个库,可以在浏览器和服务器之间实现实时、双向和基于事件的通信。

2、服务端的基本使用

  • io.on('connection', (socket) => {}):监听客户端连接,回调函数会传递本次的socket

  • socket.on('String', (msg) => {}):监听客户端消息

  • socket.emit('String', (msg) => {}):发送消息

3、客户端的基本使用

  • io('ws://127.0.0.1:7001/'):连接服务端

  • socket.on('connect', (msg) => {}):连接服务端成功

  • socket.on('String', (msg) => {}):监听服务端消息

  • socket.emit('String', (msg) => {}):发送消息

安装 socket.io 相关 npm 包

在接入 socket.io 之前,需要安装一下 npm 包

1、Server 服务端

yarn add egg-socket.io

2、Client 客户端

yarn add socket.io-client

笔者说一下上面安装的 npm 包的原因

  • egg-socket.io 插件:会将 socket.io 实例挂载在 app 上提供使用

  • socket.io-client:用于与服务端建立连接

  • 思考 & 遇到的问题

  1. 为什么不直接在客户端使用原生 Websocket 连接服务端 ?

    Answer:笔者尝试了一下在代码中接入原生 Websocket,发现并不能生效,然后就去查找了一下相关的资料。

    据相关文档解释,socket.io 不是 Websocket,它只是将 Websocket 和轮询机制以及其他的实时通信方式封装成了通用的接口,并且在服务端实现了这些实时机制相应的代码,也就是说,Websocket 仅仅是 socket.io 实现实时通信的一个子集,因此 Websocket 客户端连接不上 socket.io 的服务端,同理, Websocket 服务端也连接不上 socket.io 的客户端

  2. 为什么不用使用 @types/socket.io-client 引用 socket.io-client 的相关声明文件 ?

    Answer:比如使用 React 和 React-Dom 都需要引用相关的声名文件,也就是需要 yarn add @types/react @types/react-dom,但是 socket.io-client 它已经内置提供了相关的声明文件,所以我们使用到了 Ts 是不用安装 @types/socket.io-client 的,也会有相关代码补全、接口提示的功能

image.png

服务端配置 egg-socket.io

1、开启插件

既然是插件,首先就应该配置一下 plugin 文件

// app/config/plugin.js
exports.io = {
    enable: true,
    package: 'egg-socket.io'
};

2、配置 config.js

// app/config/config.default.js
exports.io = {
    init: { }, // passed to engine.io
    namespace: {
        '/': {
            connectionMiddleware: [ 'connection' ],
            packetMiddleware: []
        }
        //'/example': {
        //    connectionMiddleware: [ 'connection' ],
        //    packetMiddleware: []
        //}
    }
};

上面的配置还挺重要的,解释说明一下几个参数的意义:

  • namespace:在前面提到了 socket.io 连接服务端的方式是 io('ws://127.0.0.1:7001/')。在上面的配置中,笔者只配置了 '/' 有关的配置,所以连接的时候是在 127.0.0.1:7001 后面加上 /,如果说有其他的命名空间,假设为 '/example',则连接的方式为 io('ws://127.0.0.1:7001/example')

  • connectionMiddleware:预处理器中间件,在建立连接的时候会通过该中间件,可在这个地方走一些预处理

  • packetMiddleware:通常用于对消息的预处理,本文暂未使用到该参数,不做过多解释了

服务端使用 egg-socket.io

1、目录结构

在使用之前我们先确认一下当前的目录结构,笔者新增了一个 io 文件夹存放相关的文件

  • app/io/middleware

  • app/io/controller

2、添加 Middleware 预处理器中间件

// app/io/middleware/connection.js
module.exports = () => {
    return async (ctx, next) => {
        ctx.logger.info('*** SOCKET IO CONNECTION SUCCESS ***');
        ctx.socket.emit('serverMsg', '\r\n*** SOCKET IO CONNECTION SUCCESS ***\r\n')
        await next();
        ctx.socket.emit('serverMsg', '\r\n*** SOCKET IO DISCONNECTION ***\r\n')
    };
};

在建立连接和结束通信的时候都会经过该中间件

3、添加 Controller 控制器

// app/io/controller/home.js
module.exports = app => {
    return class Controller extends app.Controller {
        async getShellCommand () {
            const { ctx, logger, app } = this;
            const command = ctx.args[0];
            ctx.socket.emit('res', { code: 1, content: 'Message received' });
            logger.info(' ======= command ======= ', command)
        }
    }
}

egg中可在Controller 中处理相应的事件,笔者在 home.js 中,添加定义了一个 getShellCommand 的方法,在这里可以处理在 terminal 中发送过来的 shell 语句

4、配置路由

// app/router.js
module.exports = app => {
    const { io } = app
    io.of('/').route('getShellCommand',  io.controller.home.getShellCommand)
    // io.of('/example').route('getShellCommand',  io.controller.home.getShellCommand)
}
  • of('/'): 在上面路由文件,可以在 app 上拿到 io 实例,然后通过 of('/') 匹配到在配置文件中配置的命名空间 '/',假设需要匹配 '/example',则使用 app.io.of('/example') 即可

  • getShellCommand 事件: 对于在'/'命名空间下,监听到的 getShellCommand 事件将会由 io.controller.home.getShellCommand 的方法处理,getShellCommand 事件可以在客户端由 socket.emit('getShellCommand', { command: 'cd /' }) 触发

以上 Doreamon 服务端上已经相关配置和使用已经搭好了,那么接下来需要在客户端接受到服务端发送过来的信息

客户端使用 socket.io-client

1、客户端建立连接

// app/web/utils/socket.ts
import io from 'socket.io-client'

export const Socket = io('ws://127.0.0.1:7001/', { transports: ['websocket'] })

2、使用 socket.io-client

// app/web/pages/webTernimal.tsx
import { Socket } from  '@/utils/socket'

const WebTerminal: React.FC = () => {
    ...
    const initSocket = () => {
        socket.on('connect', () => {
            console.log('*** SOCKET IO SERVER CONNECTION SUCCESS ***')
        })

        // 发送消息
        socket.send('*** CLIENT SEND MESSAGE ***')
        socket.emit('getShellCommand', { command: 'cd /' })
    }
    ...

    useEffect(() => {
        initTerminal()
        initSocket()
    }, [])
}
  • 思考 & 遇到的问题
  1. 监听事件是否一直存在 ?

客户端与服务端建立连接

经过以上的操作,客户端与服务端就已经建立好连接了,笔者再介绍一下在开发中遇到的一些的可说明的点

1、建立连接后,Code 为 101

image.png

查看该请求,发现当这个我们发送建立连接的请求的时候,得到的状态码是101,并且附带说明 Switching Protocols,那么这个什么意思呢?

首先,这个101的 code 码表示切换协议,在该请求可以看到有以下几个请求头:

  • Upgrade:websocket 表示请求发起 Websocket 协议
  • Sec-WebSocket-Key:浏览器随机生成的 base 64 编码,与响应中的 Sec-WebSocket-Accep 字段对应,提供基本的防护,防止一些意外的链接和恶意的链接
  • Sec-WebSocket-Version:告诉服务器发起的 Websocket 协议的版本

在发送上面的信息之后,在返回的响应中可以看到有一下几个请求头:

  • Sec-WebSocket-Accep:经过服务器确认,加密后的Sec-WebSocket-Key
  • Connection: Upgrade 表示已经切换协议了
  • Upgrade:websocket 表示切换的协议为 Websocket

由此,连接就建立了

2、数据包相关 code 码

建立连接之后就可以发送消息,如下所示

image.png

可以看到每条消息都带有一些数字,介绍几个基本的数字的含义:

第一位数字,表示包的类型

  • 0:open,在打开新传输时从服务器发送
  • 1:close,请求关闭此传输,但不关闭连接本身。
  • 2:ping,由客户端发送,服务器应该用包含相同数据的乓包应答,如:
    1. 客户端发送2 probe
    2. 服务端发送3 probe 发送心跳包反馈连接正常
  • 3:pong,由服务端发送
  • 4:message,实际消息,客户端和服务器应该使用数据调用它们的回调

第二位数字,表示包的消息的类型

  • 0,如 40,发送消息确认连接
  • 2,如 42,表示发送的消息事件

其实socket.io的通信原理也能展开说一说,但是本篇文章的主题不在于此,就不展开描述了,再过多描述就偏离主题了。

以上,就在 Doreamon 中接入了 socket.io,但是 Web Terminal 的搭建还不够的,还需要对输入的 shell 语句进行处理,接下来则需要接入 ssh2 处理基本的 shell 语句,由此开始了本篇文章的第二部分

在 Doreamon 中接入 ssh2

最开始的时候,笔者想直接使用 egg 框架中支持的 egg-ssh,但是这个插件的使用场景和 Doreamon 所需要的不太一样,egg-ssh 是在配置中先配置好一台服务器的host和用户、密码等信息,再去执行相关语句,但是在 Doreamon 的使用场景中,需要的是免登录执行相关的shell语句,其中,配置文件中是有多台服务器的,并不是固定写死的一台服务起。因此,笔者使用了 ssh2,每当我们进入服务器时,先直接登录该台服务器,再执行输入的shell语句。

安装 ssh2 npm 包

yarn add ssh2

服务端配置请求

首先,当进入一台主机的时候,需要先使用用户、密码等信息登录,笔者是需要拿到相应的服务器的信息的,但是由于时间紧迫,笔者先固定写死一台服务器的信息。

假设现在使用的是 ... 这台主机,我们需要先登录这台服务器,使用的用户名和密码分别为root和***

// app/io/controller/home.js
const { createNewServer } = require('../../utils/createServer');

module.exports = app => {
    return class Controller extends app.Controller {

        // async getShellCommand () {
        //     const { ctx, logger, app } = this;
        //     const command = ctx.args[0];
        //     ctx.socket.emit('res', { code: 1, content: 'Message received' });
        //     logger.info(' ======= command ======= ', command)
        // }

        async loginServer () {
            const { ctx } = this
            createNewServer({
                host: '*.*.*.*',
                username: 'root',
                password: '***'
            }, ctx)
        }
    }
}


// app/router.js
io.of('/').route('loginServer',  io.controller.home.loginServer)

上面代码笔者做了以下两点点:

1、写了一个 loginServer 方法用来登录 ... 这台服务器,之前添加的 getShellCommand 就不需要了

2、在路由中新增 loginServer 请求,并使用 io.controller.home.loginServer中的方法处理该请求

3、登录服务器使用了createNewServer这个方法

登录服务器

// app/utils/createServer.js
const SSHClient = require('ssh2').Client
const utf8 = require('utf8')


const createNewServer = (machineConfig, ctx) => {
    const ssh = new SSHClient();
    const { host, username, password } = machineConfig
    const { socket } = ctx

    // 连接成功
    ssh.on('ready', () => {

        socket.emit('serverMsg', '\r\n*** SSH CONNECTION SUCCESS ***\r\n')

        ssh.shell((err, stream) => {

            if (err) {
                return socket.send('\r\n*** SSH SHELL ERROR: ' + err.message + ' ***\r\n');
            }

            socket.on('shellCommand', (command) => {
                stream.write(command)
            })

            stream.on('data', (msg) => {
                socket.emit('serverMsg', utf8.decode(msg.toString('binary')))

            }).on('close',  () => {
                ssh.end();
            });
        })

    }).on('close', () => {
        socket.emit('serverMsg', '\r\n*** SSH CONNECTION CLOSED ***\r\n')
    }).on('error', (err) => {
        socket.emit('serverMsg', '\r\n*** SSH CONNECTION ERROR: ' + err.message + ' ***\r\n')
    }).connect({
        port: 22,
        host,
        username,
        password
    })
}

module.exports = {
  createNewServer
}

以上代码做了以下几点:

1、通过 connect 登录指定的服务器

2、ssh.shell 中的 stream.write 将会执行经过监听 shellCommand 事件发送过来的基本的 shell 语句

3、ssh.shell 中的 stream.on 会监听到执行语句后的结果,发送 serverMsg 事件携带相关结果传递给客户端

发送shell语句

// app/web/pages/webTernimal/index.tsx
const WebTerminal: React.FC = () => {
    ...
    const handleInputText = () => {
        terminal.write('\r\n')
        if (!inputText.trim()) {
            terminal.prompt()
            return
        }

        if (inputTextList.indexOf(inputText) === -1) {
            inputTextList.push(inputText)
            currentIndex = inputTextList.length
        }
        // socket 通信
        socket.emit('shellCommand', inputText + '\r')
        terminal.prompt()
    }
    
    ...
    
    useEffect(() => {
        if (terminal) {
            onKeyAction()

            socket.on('serverMsg', (res: string) => {
                console.log('*** SERVER MESSAGE ***', res)
                if (res) terminal.write(res)
            })

        }
    }, [terminal])
    ...
    
}

上面代码主要做了以下操作

1、在输入完成,将输入的命令发送给服务端,需要注意的是,因为ssh2他会通过换行来区分是否输入完成,所以需要加上换行符socket.emit('shellCommand', inputText + '\r')

2、在terminal 实例生成后,socket 添加 serverMsg 的监听事件

在接入ssh2的时候,因为相关的npm的依赖包,连中文的使用文档都没有,只在npm上面附带了使用方法,实在是太简陋了,鉴于本篇对于此没有过多的使用,所以笔者对于 ssh2 也不做过多的介绍

image.png

不过这个 npm 包在几十天前还有在发包,可是相关的文档却少之又少,可见写一份好的官方文档是多么重要

最后,经过以上的操作,一个简易的 web ternimal 就搭建好啦~

image.png

还需要完善的点

1、可以看到,当输入命令的时候,是可以删除[root@*-*-*-* /]# 的,当按上下按钮的时候也存在问题,所以这输入部分的处理还需要修改

2、socket.io 和 ssh2 断开连接的处理,目前都只是添加了确立连接的和通信的一些处理,还没做断开连接的处理

总结

本篇文章主要分为两部分,一部分是接入websocket,一部分是接入ssh2,在查阅相关资料的时候,发现很多介绍或者 demo 都写的十分的简陋,本篇文章也是打着能够让大家看懂的目的书写的。笔者对于这两部分的接触时间也不多,有一些写的不对的,或者理解错误的地方,欢迎大家能够指出。写到了这里,Web Terminal 这个主题算是结束了,最后希望能够让大家了解到这个搭建的过程,有所收获~

相关链接