使用flask+tornado搭建一个webssh工具

xshell确实好用,但是不便于辗转流连于多个电脑之间,虽然用onedrive解决了多台电脑之间的秘钥文件的存储问题,但是令人讨厌的秘钥密码却总是如幽灵般的闪现,偶尔记不住某个服务器的密码就要翻箱倒柜地寻找密码薄。因此,一直有一个想法,就是搞一套webssh,像登录网站那样登录ssh,这样不论在哪台电脑上工作,只要有一个浏览器,就可以随时登录服务器,岂不快哉。

原理

首先要了解我们使用webssh的工作原理,传统单工的http协议并不能满足我们的要求,我们要求实时双工通信,因此前端的协议就是websocket,前端处理终端字符有一个非常著名的库xterm.js, 最近非常流行的vscode的terminal就是使用xterm.js编写的。后端websocket服务采用tornado, ssh通信及认证等工作交给python的paramiko库。

原理图大概如下这个样子:

xterm.js的使用

了解了原理,接下来我们来实现它。首先我们先看一下xterm.js这个工具的使用。
xterm.js是一个非常强大的库,可以帮我们简化很多的工作和麻烦,有了xterm.js,我们不需要考虑terminal下的各种字符的问题。

xterm.js的官网

我们使用的代码比较简单,只需要引入xterm.js,然后创建一个websocket即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<head>
<link rel="stylesheet" href="{{ url_for('static', filename='dist/xterm.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='dist/addons/fullscreen/fullscreen.css') }}" />
<!-- <link rel="stylesheet" href="/dist/style.css" /> -->
<script src="{{ url_for('static', filename='dist/xterm.js') }}"></script>
<script src="{{ url_for('static', filename='dist/addons/fullscreen/fullscreen.js') }}"></script>
<script src="{{ url_for('static', filename='dist/addons/fit/fit.js') }}"></script>
</head>

<body>
<div class="container">
<div id="terminal-container"></div>
</div>
<script>
// terminado.apply(Terminal);

console.log("加载addons");
Terminal.applyAddon(fit);

// 获取网页高度和宽度
var cols = parseInt(document.documentElement.clientWidth/9.5,10)
var rows = parseInt(document.documentElement.clientHeight/18,10)


var term = new Terminal({
cols: cols,
rows: rows
}),
protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://',
socketURL = protocol + location.hostname + ((location.port) ? (':' + location.port) : '') + "/websocket/{{server_id}}";
sock = new WebSocket(socketURL);

sock.addEventListener('open', function () {
// term(sock);
//发送当前窗口大小
sock.send("size:" + cols + "," + rows)
term.on('data', function (data) {
sock.send(data);
});

});

sock.addEventListener("message", function (msg) {
term.write(msg.data)
});

term.open(document.getElementById('terminal-container'));
// term.write("Hello");
term.fit();
// term.toggleFullScreen(true);
</script>
</body>

xterm.js自带了两个插件,一个fit,另一个fullscreen,使用fullscreen的时候需要注意,不要忘了引用css文件。详细使用说明请参考官网说明文档。

tornado 中使用websocket

tornado是一个强大的web应用框架,其最受关注的功能是对异步的支持,它使得处理非阻塞请求更容易,最终导致更高效的处理以及更好的可扩展性,同时也支持websocket. 所以,我们这里选用的websocket实现就是tornado。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

from tornado.web import FallbackHandler, Application, StaticFileHandler
from tornado.wsgi import WSGIContainer
from tornado.ioloop import IOLoop
from app.ws.server import SshHandler

rom tornado.httpserver import HTTPServer
import ssl
import os

app = WSGIContainer(app)
handlers = [
(r"/websocket/(.*)", SshHandler,{}),
(r"/(.*)", FallbackHandler, dict(fallback=app))
]

application = Application(handlers,debug=True)

这里不仅使用了websocket,还是用flask部分代码,是因为我的项目中http请求是用flask来实现的。

处理secure websocket

webssh这么敏感的业务肯定是要跑在secure websocket上的,tornado添加证书和秘钥也非常简单:

1
2
3
4
5
6
7
httpserver = HTTPServer(application,ssl_options={
"certfile": os.path.join(config.read("secure_path"), "1_home.mixoo.cn_bundle.crt"),
"keyfile": os.path.join(config.read("secure_path"), "2_home.mixoo.cn.key"),
})

httpserver.listen(int(config.read("PORT")))
IOLoop.current().start()

这样就构建好了服务端的websocket。

paramiko

xterm.js官方使用的是termindo,一个由tornado编写而来的演示框架,但是它只支持本机的ssh,并不能满足我们希望连接其他主机的跳板需求。
这里是用的解决方案是python的paramiko库,paramiko提供很多接口供我们使用,能让我们轻松地完成ssh连接操作。

通常我们远程登录主机使用的验证方式有两种,一种是用户名和密码认证,另一种是私钥公钥认证,有时候私钥还会有密码。这些在xshell等工具中都有配置,我们现在就用paramiko来实现同样的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

class SSH(object):

def __init__(self, host, port, user, password=None, keyfile=None, passphrase=None):
self.host = host
self.port = port
self.user = user
self.password = password
self.keyfile = keyfile
self._ssh = paramiko.SSHClient()
self._ssh.load_system_host_keys()
self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
k = keyfile and paramiko.RSAKey.from_private_key_file(
keyfile, password=passphrase) or None
self._ssh.connect(hostname=host, port=port, username=user,
password=password, pkey=k)
self._chanel = self._ssh.invoke_shell(
term='xterm')

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

def send(self, msg):
self._chanel.send(msg)

def read(self):
return self._chanel.recv(10000)

核心代码不多,主要的方法就是connect,在其中要将用户名和密码、端口等信息都配置好。如果有私钥,私钥又有密码,就使用from_private_key_file方法把私钥文件名和密码传入。invoke_shell方法返回一个channel,拿到这个channal就可以像xshell一样打开一个terminal一样来操作了。

跳坑指南

Nignx部署400错误

本地测试完,部署服务器的时候碰到nginx返回400错误。原因是忘了在nginx配置中将协议升级为websocket:

1
2
3
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

RuntimeError: There is no current event loop in thread ‘Thread-1’.

也是在部署服务器的时候出现的,原因是本地tornado版本是4.5.3,而服务器版本变成了5.1. 在5.1版本中使用多线程,就会出现上述错误。解决方案就是使用asyncio的set_event_loop方法:

1
2
3
4
5
6
import asyncio
def _reading(self):
asyncio.set_event_loop(asyncio.new_event_loop())
while True:
data = self.ssh.read()
self.write_message(data)

Websocket报403错误

也是因为服务器版本5.1的问题,解决方案,在websocket的handler中添加一个check_origin方法:

1
2
def check_origin(self, origin):
return True

运行界面:

点击终端进入webssh界面:

源代码地址