WebSSHっぽいことをしてみる。

WebSocket経由でSSHの操作を行います。 といっても、今回行うのはChromeのSource ShellのようにWebブラウザをSSHとして使うのではなく、 あくまでも、ウェブ経由でサーバ側のSSHプロセスを操作するというものです。

今回参考にしたのは、githubにあるこのリポジトリ
https://github.com/aluzzardi/wssh
WSSHってなんかマルウェアの名前みたい、というか使い方によってはまるっきりマルウェアなんでしょうけど...
ここで必要なの物は以下の3つ、なんとすべてpythonパッケージで構築ができます。 ・Webサーバ -> Flask ・WebSocket -> genevt ・sshクライアント -> paramiko Flaskは軽量なWebフレームワークライブラリ(有名どころでいうと、djangoRuby on Rails見たいなもの)
geventは並行ライブラリ、socketを利用したネットワークアプリケーションを作成するときに便利です。
paramikoはpythonSSH接続ができるようになるライブラリ、pythonのttyライブラリと組み合わせることで、 linuxSSHクライアントと同等の動作が可能になります。

Flask

基本的な使い方はこう

from flask import Flask, request, abort, render_template
from jinja2 import FileSystemLoader
import os
app = Flask(__name__) #インスタンスの作成

@app.route('/')#ルートにアクセスした場合の処理
def index():
    return render_template('index.html') #index.htmlがブラウザに表示される。

if __name__ == "__main__":
    #rootpathの指定と、何かしらファイルを参照する際のtemplatesフォルダを指定する。
    #index.htmlはtemplatesフォルダに入れておくと、render_template(~~~)で簡単に参照できる。
    root_path = os.path.dirname('/')
    app.jinja_loader = FileSystemLoader(os.path.join(root_path,'templates'))
    app.run() #サーバの立ち上げ

デフォルトではlocalhost:5000向けにサーバが立ち上がります。 テンプレートディレクトリの指定などはdjangoに似ていてわかりやすいです。

gevent

pythonでウェブソケットを実装しようとして始めて知ったが、このgeventがかなり使いやすいです。 基本的には、複数の処理を並列して実行可能にするライブラリと考えて良いと思います。 バックエンドで何らかの待ちうけ処理を行い、かつ、その裏で他のプログラムを実行したい場合に超有効。 ネットワークアプリなんかは典型的な例です。 今回はwebsocket -> sshssh -> websocketの入出力をgeventで並列実行する。 簡単に使い方を紹介。

from flask import Flask, request, abort, render_template
from werkzeug.exceptions import BadRequest
from gevent import monkey
monkey.patch_all()
import gevent

import socket
import time

app = Flask(__name__)


class WSSHBridge(object):
    def __init__(self,websocket):
        self._websocket = websocket
        self._tasks = []

    def _forward_inbound(self):
        try:
            while True:
                data = self._websocket.receive()
                if not data:
                    return
                print data
        finelly:
            self.close()
    
    def _forward_outbound(self):
        try:
            while True:
                time.sleep(5)
                self._websocket.send("return message")

    def _bridge(self):
        self._tasks = [
            gevent.spawn(self._forward_inbound),
            gevent.spawn(self._forward_outbound)
        ]
        gevent.joinall(self._tasks)
    
@app.route('/')
def index():
    bridge = WSSHBridge(request.environ['wsgi.websocket'])
    bridge._bridge()

if __name__ == "__main__":
    #app.run()
    from gevent.pywsgi import WSGIServer
    from geventwebsocket.handler import WebSocketHandler
    from jinja2 import FileSystemLoader
    import os
    root_path = os.path.dirname('/')
    app.jinja_loader = FileSystemLoader(os.path.join(root_path,'templates'))
    http_server = WSGIServer(('0.0.0.0',5000),app,log=None,handler_class=WebSocketHandler)
    try:
        http_server.serve_forever()
    except KeyboardInterrupt:
        pass

上記はwebsocketでクライアントから送られてくるメッセージをprintで標準出力し、サーバ側は5秒おきにOKの返事を返す仕組みです。
geventで並列動作しているので、両者が独立して動くきます。
(何のためのプログラムなんでしょうか...まあなんかいろいろできそうですね。) wscatを使えば簡単にウェブソケットを試せるのでお勧めです。
(ちょっと試験したいだけなのに、javascriptで書くはめんどくさいので。。。)

wscat --connect ws://localhost:5000/

ssh

いよいよSSH接続をして見ます。 pythonSSH接続をするにはparamikoがお勧めです。
何よりも簡単だし、SSHのプロセスを理解していない私でも使えます。 ちなみに、paramikoを使えばSCPやSFTPなんかもできちゃいます。 下記は指定のサーバにssh接続して、コマンドを実行する例。

import paramiko
from paramiko import PasswordRequiredException
from paramiko.dsskey import DSSKey
from paramiko.rsakey import RSAKey
from paramiko.ssh_exception import SSHException


def open(self, hostname, port=22, username=None,password=None,private_key=None, key_passphrase=None, allow_agent=False,timeout=None):
    pkey=None
    self._ssh.connect(
        hostname=hostname,
        port=port,
        username=username,
        password=password,
        pkey=pkey,
        timeout=timeout,
        allow_agent=allow_agent,
        look_for_keys=False)


ssh = pramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
open('localhost',22,'root','password')
stdin, stdout, stderr = ssh.exec_command('ls -l')

set_missing_host_key_policyは接続した際に、サーバホストキーがなかった場合の挙動を定義しています。 よく、最初にサーバへsshするときに、「~~~~yes no」みたいに聞かれますよね? あれです。今回はここで処理がとまると面倒なので、paramiko.AutoAddPolicy()で自動的にホストキーを追加しています。 いつもの質問に自動的にyesって答えるということです。
connect()でSSHのコネクションを張ります。 connectの中のパラメータはサーバ側に設定したものにあわせて設定、今回は鍵の設定をしていないのでほとんどNoneですが
最後、コマンドの実行はssh.exec_command()です。
実行結果はstdoutに帰ってきます。

Websocket経由でSSH

パーツが揃ったので、いよいよ本題の「WebSocket経由でSSH」を実行してみます。
といってもやることは簡単で、websocketで受け取った入力をコマンドとしてparamikoで実行して、 その結果をまたwebsocketでクライアントに返すだけです。
下記のプログラムを実行すると、5000番ポートでHTTPリクエストを待ち受け、ブラウザ(index.html)とpythonプロセスとの間でwebsocketを使ったやり取りをしています。

from gevent import monkey
monkey.patch_all()

from flask import Flask, request, abort, render_template
from werkzeug.exceptions import BadRequest
import gevent
from gevent.socket import wait_read, wait_write

import paramiko
from paramiko import PasswordRequiredException
from paramiko.dsskey import DSSKey
from paramiko.rsakey import RSAKey
from paramiko.ssh_exception import SSHException

import socket
import time

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/web/')
def webS():
    bridge = WSSHBridge(request.environ['wsgi.websocket'])
    bridge.execute()

class WSSHBridge(object):
    def __init__(self,websocket):
        self._websocket = websocket
        self._ssh = paramiko.SSHClient()
        self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        self.open('localhost',22,'root','password')
        self._transport = self._ssh.get_transport()
        self._channel = self._transport.open_session()
        self._channel.get_pty('xterm')
        self._tasks = []

    def open(self, hostname, port=22, username=None,password=None,private_key=None, key_passphrase=None, allow_agent=False,timeout=None):
        pkey = None
        self._ssh.connect(
            hostname=hostname,
            port=port,
            username=username,
            password=password,
            pkey=pkey,
            timeout=timeout,
            allow_agent=allow_agent,
            look_for_keys=False)

    def _forward_inbound(self):
        try:
            while True:
                data = self._websocket.receive()
                self._channel = self._transport.open_session()
                self._channel.get_pty('xterm')
                self._channel.exec_command(data)
                while True:
                    data = self._channel.recv(1024)
                    self._websocket.send(data)
                    if len(data) == 0:
                        break
        finally:
            self.close()

    def _bridge(self):
        self._channel.setblocking(False)
        self._channel.settimeout(0.0)
        self._tasks = [
            gevent.spawn(self._forward_inbound)
        ]
        gevent.joinall(self._tasks)

    def execute(self):
        self._bridge()
        self._channel.close()

    def close(self):
        gevent.killall(self._tasks, block = True)
        self._tasks = []
        self._ssh.close()

if __name__ == "__main__":
    from gevent.pywsgi import WSGIServer
    from geventwebsocket.handler import WebSocketHandler
    from jinja2 import FileSystemLoader
    import os
    root_path = os.path.dirname('/')
    app.jinja_loader = FileSystemLoader(os.path.join(root_path,'templates'))
    http_server = WSGIServer(('0.0.0.0',5000),app,log=None,handler_class=WebSocketHandler)
    try:
        http_server.serve_forever()
    except KeyboardInterrupt:
        pass

さらに、このpythonプロセスとwebsocketで通信するjavascriptのプログラム(index.html)が以下
これはただ単純にwebsocketのチャットプログラムで、チャット相手がSSHサーバになったと考えればよい感じ。

<!DOCTYPR html>
<html>
<head>
    <title>WebSSH</title>
    <script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
    <script type="text/javascript">
        var uri = "ws://"+ location.host +"/web/";
        console.log(uri);
        var webSocket = null;

        function init(){
            $("[data-name='message']").keypress(press);
            open();
        }

        function open(){
            if(webSocket == null){
                webSocket = new WebSocket(uri);
                webSocket.onopen = onOpen;
                webSocket.onmessage=onMessage;
                webSocket.onclose = onClose;
                webSocket.onerror = onError;
            }
        }

        function onOpen(event){
            chat("connected");
        }
        function onMessage(event){
            if(event && event.data){
                chat(event.data);
            }
        }

        function onError(event){
            chat("error");
        }

        function onClose(event){
            chat("connection closed. "+ event.code);
            webSocket = null;
            setTimeout("open()",3000);
        }

        function press(event){
            if(event && event.which == 13){
                $("[data-name='chat']").append("<div>root@ubuntu# "+$("[data-name='message']").val()+"</div>");
                var message = $("[data-name='message']").val();
                if(message && webSocket){
                    webSocket.send(""+message);
                    $("[data-name='message']").val("");
                }
            }
        }

        function chat(message){
            var chats = $("[data-name='chat']").find("div");
            while (chats.length >= 100){
                chats = chats.last().remove();
            }
            var msgtag = $('<div>').text(message);
            $("[data-name='chat']").append(msgtag);
        }
        $(init);
    </script>
</head>
<style>
body{
    background-color:#000000;
    color:#00ff00;
    font-family:'arial black';
}
input[type="text"], textarea {
    background-color : #000000;
    color:#00ff00;
    font-family:'arial black';
}
input{
    border:#000000;
}
</style>
<body>
    <div data-name="chat"></div>
    root@ubuntu#
    <input type="text" data-name="message" size="100"/>
    <hr />
</body>
</html>

こんな感じで動きます。ttyを転送してるわけじゃないから、viエディタとか使えませんけどね... f:id:m-masataka:20161102170735p:plain