前言
笔者在上一次关于 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:用于与服务端建立连接 -
思考 & 遇到的问题
-
为什么不直接在客户端使用原生 Websocket 连接服务端 ?
Answer:笔者尝试了一下在代码中接入原生 Websocket,发现并不能生效,然后就去查找了一下相关的资料。
据相关文档解释,socket.io 不是 Websocket,它只是将 Websocket 和轮询机制以及其他的实时通信方式封装成了通用的接口,并且在服务端实现了这些实时机制相应的代码,也就是说,Websocket 仅仅是 socket.io 实现实时通信的一个子集,因此 Websocket 客户端连接不上 socket.io 的服务端,同理, Websocket 服务端也连接不上 socket.io 的客户端
-
为什么不用使用
@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的,也会有相关代码补全、接口提示的功能
服务端配置 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、建立连接后,Code 为 101
查看该请求,发现当这个我们发送建立连接的请求的时候,得到的状态码是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 码
建立连接之后就可以发送消息,如下所示
可以看到每条消息都带有一些数字,介绍几个基本的数字的含义:
第一位数字,表示包的类型
- 0:open,在打开新传输时从服务器发送
- 1:close,请求关闭此传输,但不关闭连接本身。
- 2:ping,由客户端发送,服务器应该用包含相同数据的乓包应答,如:
- 客户端发送2 probe
- 服务端发送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 也不做过多的介绍
不过这个 npm 包在几十天前还有在发包,可是相关的文档却少之又少,可见写一份好的官方文档是多么重要
最后,经过以上的操作,一个简易的 web ternimal 就搭建好啦~
还需要完善的点
1、可以看到,当输入命令的时候,是可以删除[root@*-*-*-* /]# 的,当按上下按钮的时候也存在问题,所以这输入部分的处理还需要修改
2、socket.io 和 ssh2 断开连接的处理,目前都只是添加了确立连接的和通信的一些处理,还没做断开连接的处理
总结
本篇文章主要分为两部分,一部分是接入websocket,一部分是接入ssh2,在查阅相关资料的时候,发现很多介绍或者 demo 都写的十分的简陋,本篇文章也是打着能够让大家看懂的目的书写的。笔者对于这两部分的接触时间也不多,有一些写的不对的,或者理解错误的地方,欢迎大家能够指出。写到了这里,Web Terminal 这个主题算是结束了,最后希望能够让大家了解到这个搭建的过程,有所收获~
相关链接
socket.io官方文档:socket.io/docs/v4/- egg 官方文档: eggjs.org/zh-cn/
egg-socket.io官方文档:eggjs.org/zh-cn/tutor…ssh2: www.npmjs.com/package/ssh…- 如何搭建一个简易的 Web Terminal(一):juejin.cn/post/698238…
- 完整代码:github.com/DTStack/dor…