前言

怎样优雅的运行Linux命令并实时的显示结果,就像Xshell一样呢?那就要属WebSSH了。
基于Web的SSH有很多,基于Python的SSH也有很多,这些都是直接通信,中间没有额外管理。但是以Django为中转桥梁结合websocket和paramiko实现的,网上就很少了。下面是我结合网上参考后的实现图和原理讲解:

项目展示

1.png
2.png
3.png

所需技术

  • websocket 目前市面上大多数的 webssh 都是基于 websocket 协议完成的
  • django-channels django 的第三方插件, 为 django 提供 websocket 支持
  • xterm.js 前端模拟 shell 终端的一个库
  • paramiko python 下对 ssh2 封装的一个库

如何将所需技术整合起来

  1. xterm.js 在浏览器端模拟 shell 终端, 监听用户输入通过 websocket 将用户输入的内容上传到 django
  2. django 接受到用户上传的内容, 将用户在前端页面输入的内容通过 paramiko 建立的 ssh 通道上传到远程服务器执行
  3. paramiko 将远程服务器的处理结果返回给 django
  4. django 将 paramiko 返回的结果通过 websocket 返回给用户
  5. xterm.js 接收 django 返回的数据并将其写入前端页面
  6. lrzsz 基于zmodem协议实现的文件传输

流程图

4.png

整个数据流:用户打开浏览器--》浏览器发送websocket请求给Django建立长连接--》Django与要操作的服务器建立SSH通道,实时的将收到的用户数据发送给SSH后的主机,并将主机执行的结果数据返回给浏览器

操作物理机或者虚拟机的时候我们可以使用Paramiko模块来建立SSH长连接隧道,Paramiko模块建立SSH长连接通道的方法如下:

# 实例化SSHClient
ssh_client = paramiko.SSHClient()
# 当远程服务器没有本地主机的密钥时自动添加到本地,这样不用在建立连接的时候输入yes或no进行确认
ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# 用key进行认证
if ssh_key:
    pass
else:
    # 用账号密码的方式进行认证
    ssh_client.connect(username=user, password=password, hostname=host, port=port, timeout=timeout)

# 打开ssh通道,建立长连接
transport = ssh_client.get_transport()
self.channel = transport.open_session()
# 获取ssh通道,并设置term和终端大小
self.channel.get_pty(term=term, width=pty_width, height=pty_height)
# 激活终端,正常登陆
self.channel.invoke_shell()
# 一开始展示Linux欢迎相关内容,后面不进入此方法
for i in range(2):
    recv = self.channel.recv(1024).decode('utf-8')
    self.message['status'] = 0
    self.message['message'] = recv
    message = json.dumps(self.message)
    self.websocker.send(message)
    self.res += recv

# 创建3个线程将服务器返回的数据发送到django websocket(1个线程都可以)
Thread(target=self.websocket_to_django).start()
# Thread(target=self.websocket_to_django).start()
# Thread(target=self.websocket_to_django).start()

连接建立,可以通过如下方法给SSH通道接收数据和发送数据:

self.channel.recv(nbytes)
self.channel.send(data)

当然SSH返回的数据也可以通过如下方法持续的输出给Websocket:

while not self.channel.exit_status_ready():
    data = self.channel.recv(40960)
    if not len(data):
        return

    # SSH返回的数据需要转码为utf-8,否则json序列化会失败
    data = data.decode('utf-8')
    self.message['status'] = 0
    self.message['message'] = data
    self.res += data
    message = json.dumps(self.message)
    self.websocker.send(message)

有了这些信息,实现WebSSH浏览器操作物理机或者虚拟机就不算困难了。

动态调整终端窗口大小

如果我中途调整了浏览器的大小,显示就乱了,这该怎么办? 好办, 终端窗口的大小需要浏览器和后端返回的Terminal大小保持一致,单单调整页面窗口大小或者后端返回的Terminal窗口大小都是不行的,那么从这两个方向来说明该如何动态调整窗口的大小 。

首先Paramiko模块建立的SSH通道可以通过resize_pty来动态改变返回Terminal窗口的大小,使用方法如下:

def resize_pty(self, cols, rows):
    self.ssh_channel.resize_pty(width=cols, height=rows)

然后Django的Channels每次接收到前端发过来的数据时,判断一下窗口是否有变化,如果有变化则调用上边的方法动态改变Terminal输出窗口的大小

我在实现时会给传过来的数据加个status,如果status不是0,则调用resize_pty的方法动态调整窗口大小,否则就正常调用执行命令的方法,代码如下:

def receive(self, text_data=None, bytes_data=None):
    if text_data is None:
        self.ssh.django_bytes_to_ssh(bytes_data)
    else:
        data = json.loads(text_data)
        if type(data) == dict:
            status = data['status']
            if status == 0:
                data = data['data']

                self.ssh.shell(data)
            else:
                cols = data['cols']
                rows = data['rows']
                self.ssh.resize_pty(cols=cols, rows=rows)

通过lrzsz上传下载文件

当使用Xshell或者SecureCRT终端工具时,我的所有文件传输工作都是通过lrzsz来完成的,主要是因为其简单方便,不需要额外打开sftp之类的工具,通过命令就可轻松搞定,在用了WebSSH之后一直在想,这么便捷的操作WebSSH能够实现吗?

答案是肯定的,能实现!这要感谢这个古老的文件传输协议:zmodem

zmodem采用串流的方式传输文件,是xmodem和ymodem协议的改良进化版,具有传输速度快,支持断点续传、支持完整性校验等优点,成为目前最流行的文件传输协议之一,也被众多终端所支持,例如Xshell、SecureCRT、item2等

优点之外,zmodem也有一定的局限性,其中之一便是只能可靠地传输大小不超过4GB的文件,但对于大部分场景下已够用,超大文件的传输一般也会寻求其他的传输方式

lrzsz就是基于zmodem协议实现的文件传输,linux下使用非常方便,只需要一个简单的命令就可以安装,例如centos系统安装方式如下:

yum install lrzsz

安装完成后就可以通过rz命令上传文件,或者sz命令下载文件了,这么说上传或下载其实不是很准确,在zmodem协议中,使用receive接收和send发送来解释更为准确,无论是receive还是send都是由服务端来发起

rz的意思为recevie zmodem,服务端来接收数据,对于客户端来说就是上传

sz的意思是send zmodem,服务端来发送数据,对于客户端来说就是下载

文件的传输需要服务端和客户端都支持zmodem协议,服务端通过安装lrzsz实现了对zmodem协议的支持,Xshell和SecureCRT也支持zmodem协议,所以他们能通过rz或sz命令实现文件的上传和下载,那么Web浏览器要如何支持zmodem协议呢?

我们所使用的终端工具xterm.js在3.x版本提供过zmodem扩展插件, 但很可惜 xterm v4 版本后去掉了 zmodem 插件,只能直接使用 zmodem.js 实现,但是不知道什么原因,登陆 webssh 后,第一次输出命令回车后会卡顿一下才出数据,v3.14.5 就不会卡顿,v3.14.5还可以也可以直接使用 zmodem.js,所以这里使用 v3.14.5,终端功能方面v3 和 v4 我没发现有什么多大的差别。zmodem调用系统rzsz命令实现文件上传下载了

需要注意的是zmodem是个二进制协议,只支持二进制流,所以通过websocket传输的数据必须是二进制的,在django的channel中可以通过指定发送消息的类型为bytes_data来实现websocket传输二进制数据,这是后端实现的核心:

websocket.send(bytes_data=data)

又深入研究了zmodem协议是如何实现识别的,发现了zmodem的实现原理

在服务器上执行sz命令后,会先输出b'**\x18B0800000000022d\r\x8a'这样的内容,标识文件下载开始,当文件下载结束后会输出b'OO',取这两个特殊标记之间的二进制流组合成文件,就是要下载的完整文件

rz命令类似,会在开始时输出b'rz waiting to receive.**\x18B0100000023be50\r\x8a'标记, 知道了这个规则, 就好区分用户上传和下载文件了:

zmodemszstart = b'rz\r**\x18B00000000000000\r\x8a'
zmodemszend = b'**\x18B0800000000022d\r\x8a'
zmodemrzstart = b'rz waiting to receive.**\x18B0100000023be50\r\x8a'
zmodemrzend = b'**\x18B0800000000022d\r\x8a'
zmodemcancel = b'\x18\x18\x18\x18\x18\x08\x08\x08\x08\x08'

while not self.channel.exit_status_ready():
    if self.zmodemOO:
        # 文件开始下载
        self.zmodemOO = False
        data = self.channel.recv(2)
        if not len(data):
            return
        # 文件下载结束
        if data == b'OO':
            self.websocker.send(bytes_data=data)
            continue
        else:
            data = data + self.channel.recv(40960)
    else:
        data = self.channel.recv(40960)
        if not len(data):
            return

    if self.zmodem:
        if zmodemszend in data or zmodemrzend in data:
            self.zmodem = False
            if zmodemszend in data:
                self.zmodemOO = True
        if zmodemcancel in data:
            self.zmodem = False
        self.websocker.send(bytes_data=data)
    else:
        if zmodemszstart in data or zmodemrzstart in data:
            self.zmodem = True
            self.websocker.send(bytes_data=data)
        else:
            # SSH返回的数据需要转码为utf-8,否则json序列化会失败
            data = data.decode('utf-8')
            self.message['status'] = 0
            self.message['message'] = data
            self.res += data
            message = json.dumps(self.message)
            self.websocker.send(message)
        except:
            self.close()

总结

完整代码,我已经放到GitHub上了,忘记了可以参考!

Last modification:April 19th, 2020 at 10:20 pm