pythonのWebフレームワークTornadoでhttpのプロキシサーバを作ってみた

PythonではTornadoというWebフレームワークを使うことで簡単にノンブロッキングWebサーバの開発ができるらしい。

最近ではnode.jsなどがノンブロッキングWebサーバとして注目を集めているが、文字列操作の点でpythonの方が優れていそうだったのでとりあえずpythonで実装してみることにした。

○まずはTornadoをPythonにインストール
Tornadoのインストールはpipで簡単に行える

pip install tornado

pipを使わない場合はソースコードを落としてきて、直接インストールできる。

tar xvzf tornado-4.1.tar.gz
cd tornado-4.1
python setup.py build
sudo python setup.py install

○Tornadoを使ってプロキシサーバを作成
以下のコードでプロキシサーバになる

#!/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function
import sys
import os
import tornado.httpserver
import tornado.ioloop
import tornado.iostream
import tornado.web
import tornado.httpclient
import ssl

class ProxyHandler(tornado.web.RequestHandler):
    @tornado.web.asynchronous
    def get(self):
        return self.myRequest()

    @tornado.web.asynchronous
    def post(self):
        return self.myRequest()

    def myRequest(self):
        #self.render("test.html")
        def get_response( response):
            if response.error and not isinstance(response.error,tornado.httpclient.HTTPError):
                self.set_status(500)
                self.write('500 error:\n' + str(response.error))
                self.finish()
            else:
                self.set_status(response.code)
                for header in ('Date', 'Cache-Control', 'Server', 'Content-Type', 'Location'):
                    v = response.headers.get(header)
                    if v:
                        self.set_header(header, v)
                if response.body:
                    print(self.request.uri)
                    #self.write(response.body.replace("<body".encode("utf-8"), "<script type='text/javascript'>(function(){alert('hello');})();</script><body".encode("utf-8")))
                    self.write(response.body)

                    #self.render(response.body)
                self.finish()

        req = tornado.httpclient.HTTPRequest(
            url=self.request.uri,
            method=self.request.method, body=self.request.body,
            headers=self.request.headers,
            follow_redirects=False,
            allow_nonstandard_methods=True)
        client = tornado.httpclient.AsyncHTTPClient()
        try:
            #コールバック関数にhandle_responseを指定。ここにアクセスしたレスポンスが入る
            client.fetch(req, get_response)
        except tornado.httpclient.HTTPError as e:
            if hasattr(e, 'response') and e.response:
                get_response(e.response)
            else:
                self.set_status(500)
                self.write('500 error:\n' + str(e))
                self.finish()

def run_proxy(port):
    app = tornado.web.Application(
        [(r'.*', ProxyHandler),]
    )
    http_server = tornado.httpserver.HTTPServer(app)
    http_server.listen(port)

    print("Server is up ...")
    tornado.ioloop.IOLoop.instance().start() #プロキシサーバを稼働させる

if __name__ == "__main__":
    port = 8888
    if len(sys.argv) > 1:
        port = int(sys.argv[1])

    print ("Starting cache proxy on port %d" % port)
    run_proxy(port)
    #run_ssl_proxy(8888)

やっていることとしては、ポートの8888でサーバを立てておきリクエストを受け取ったらをそのまま中継させるだけだ。
サーバを立ち上げているのは以下の部分になります。

    app = tornado.web.Application(
        [(r'.*', ProxyHandler),]
    )
    http_server = tornado.httpserver.HTTPServer(app)
    http_server.listen(port)

tornado.web.Applicationの引数で受け取るリクエストと呼びだすクラスを指定しています。
また、今回は指定していませんがSSL通信を行いたい場合は以下のようにオプションとして証明書と秘密鍵の指定が必要になります。

    app = tornado.web.Application(
        [(r'.*', ProxyHandler),],
        template_path=os.path.join(os.getcwd(),  "static"),
        static_path=os.path.join(os.getcwd(),  "static")
    )

    http_server = tornado.httpserver.HTTPServer(app, ssl_options={
        "certfile":"/var/pyTest/ca2.crt",
        "keyfile":"/var/pyTest/ca2_out.key",
        #ssl_version= ssl.PROTOCOL_TLSv1
    })

○ブラウザのプロキシとして設定する
ブラウザからアクセスする場合は、プロキシとして通す設定が必要になる。
Firefoxの場合はオプション→詳細→ネットワーク→接続設定で行うことができる。


ブラウザ設定後リクエストを飛ばしたらGET,POSTにかかわらずmyRequest()メソッドを実行するようにしています。リクエストは以下の部分で設定を行っています。内容としては受け取ったリクエストをそのままセットしているだけです。

req = tornado.httpclient.HTTPRequest(
    url=self.request.uri,
    method=self.request.method, body=self.request.body,
    headers=self.request.headers,
    follow_redirects=False,
    allow_nonstandard_methods=True)
client = tornado.httpclient.AsyncHTTPClient()

リクエストのコールバックはclient.fetch(req, get_response)で指定しています。
それからリクエストの送信とエラー時の処理を定義しています。

if hasattr(e, 'response') and e.response:
    get_response(e.response)
else:
    self.set_status(500)
    self.write('500 error:\n' + str(e))
    self.finish()

コールバックメソッド実行時結果が500エラーなら500 errorとクライアントに表示します。

if response.error and not isinstance(response.error,tornado.httpclient.HTTPError):
    self.set_status(500)
    self.write('500 error:\n' + str(response.error))
    self.finish()

結果を受け取れた場合、レスポンスヘッダとボディにそのままの内容を書き込んでいます。
self.writeの部分で文字列操作を行うと、クライアント側の表示を書き換えることができます。今回は使っておりませんが、lxmlというサードパーティモジュールがhtmlの操作に便利そうです。

else:
	self.set_status(response.code)
	for header in ('Date', 'Cache-Control', 'Server', 'Content-Type', 'Location'):
	    v = response.headers.get(header)
	    if v:
	        self.set_header(header, v)
	if response.body:
	    print(self.request.uri)
	    #self.write(response.body.replace("<body".encode("utf-8"), "<script type='text/javascript'>(function(){alert('hello');})();</script><body".encode("utf-8")))
	    self.write(response.body)

	    #self.render(response.body)
	self.finish()

結果ですが、httpプロトコルについてはうまくプロキシサーバとして動いておりましたがhttpsSSL通信を行おうとしたらエラーが発生しました。自分で準備したオレオレ証明書秘密鍵の部分でエラーが発生していたので、ちょっと調べたいと思います。

→ちょっと調べてみました。どうやらこのやり方だとクライアント→プロキシサーバ間もssl通信をする想定になっているようだが、クライアント側のFirefoxssl通信のプロキシとして指定したらクライアント→プロキシサーバ間でssl通信を行っていないようなのでエラーになってそうな気がする。
ブラウザのプロキシに指定するのではなくhttps://localhost:8888/https://google.co.jpとかでリクエストは受け取れていたので多分そうです。

まあ、httpsの場合はプロキシサーバはキャッシュの転送だけを行うルータのように動くのが前提のプロトコルのようなので、無理そうか
HTTPSプロキシの仕組み - ソフトウェアエンジニア現役続行