読者です 読者をやめる 読者になる 読者になる

TurboGears(CherryPy2.2)のリダイレクト問題

Apacheの背後で正しく動くには

このエントリは2007/11/07の再掲です

現行のTuboGearsはCherryPy2.2.1を使っています。ところが、CherryPyのリリース元にあるドキュメントはすっかり 3.0代の仕様で書かれていて、現行のTurboGearsでの解決策と食い違う点がいくつもあります。なかでも、プロクシのバックエンドとして機能しているときのリダイレクトについては、非常に厄介な部類のひとつです。

Apache等のリバースプロクシをフロントとして実行するWebアプリケーションサーバは、一般的に、リダイレクト応答をクライアントに返すさいに厄介な問題を抱えています。

HTTP仕様上、リダイレクト応答は完全表記のURLでないといけません。バックエンドのアプリケーションサーバが、クライアントから見たURLの完全表記を再現するさい、手がかりになりそうなのはリクエストヘッダのHostエントリしかありません。が、通常そこにはもう、WebブラウザがApacheに要求したリクエストはなく、Apacheアプリケーションサーバへリクエストするときにセットしたホスト名(つまりフォワード先のバックエンドサーバ)が格納されています。なので、CherryPyのレスポンスで、クライアントが Apacheに再送すべきリダイレクトURLがどこなのかを通知しようにも、それが決定できないのです。

たとえば、ブラウザが http://hoge/logout にリクエストしたのを、Apachehttp://127.0.0.1:8080/logoutに転送したとします。そこで、redirect('/')が発生したとしましょう。そのとき、CherryPyが知っているHost(=127.0.0.1:8080)をもとに/indexへのリダイレクト先を決定すると、http://127.0.0.1:8080/に変換されます。Webブラウザはクライアントマシンの8080ポートに行ってしまいますね。

そこで、プロクシとなったApacheがリクエストヘッダに追加してくれる、X-Forwarded-Hostエントリ(リクエストを最初に受け取ったホスト)を用い、この「クライアントからの直接のリクエスト先ホスト」をもとにして、リダイレクト先のURL完全表記を再現するのが適切です。これで無事、 http:/hoge/ となるでしょう。

この解決方法をCherryPyの実装がどのように提供してくれるかが、ここに書かれています。 どうやら、Apacheから転送されるアプリケーションの最上位パスセクションに、tools.proxy.onをセットする、と。

http://tools.cherrypy.org/wiki/BehindApache

…おい、オレのTurboGearsでうまくいかないじゃないか。と思って、CherryPyの各バージョンのソースを読むと、なんとこれはCherryPy3.0の話でした。2世代はどうするんだよ。

ソースを調べた結果、TurboGears1とCherryPy2では、この設定がもっとも期待に近い動作(CherryPy3でいうtools.proxy.onと同等)になりました。

[/]
base_url_filter.on = True
base_url_filter.use_x_forwarded_host = True

本当かどうかは、以下の実験コードを各パターンで実行(いろんな設定をして、プロクシする/しないアクセスの両方を比較)してみてください。

import cherrypy
class Root:
    @expose(content_type='text/plain',format="plain")
    def index(self):
        req = cherrypy.request
        cfg = cherrypy.config
        out = []
        out.append('config["server.webpath"]:%s' % cfg.get('server.webpath'))
        out.append('config["base_url_filter.on"]:%s' % cfg.get('base_url_filter.on'))
        out.append('config["base_url_filter.base_url"]:%s' % cfg.get('base_url_filter.base_url'))
        out.append('config["base_url_filter.use_x_forwarded_host"]:%s' % cfg.get('base_url_filter.use_x_forwarded_host'))
        out.append("")
        out.append('request.headers["Host"]:%s' % req.headers.get('Host'))
        out.append('request.headers["X-Forwarded-Host"]:%s' % req.headers.get('X-Forwarded-Host'))
        out.append('request.base:%s' % req.base)
        out.append('request.browser_url:%s' % req.browser_url)
        return "\n".join(out)

とはいえ、Apacheの設定を変更し、バックのCherryPyにリクエストするとき、リクエストヘッダのHostエントリを、Apache自身の名前に化かすように設定することが可能なら、それもひとつの方法として有効です。方法は同じページにありますが、それには、Apachehttpd.confに

ProxyPreserveHost on

を書くと実現できます。これだけで、どんなプロクシの先でも、最初のHostの値は維持されます。結果、CherryPyがリクエストのHostを参照したとき、何の加工もしなくても、クライアントからApacheを見たときのURLが得られます。

ただ…、ProxyPreserveHostは、httpd.confのルートレベル、もしくは、バーチャルホストに書かなければならず、共存する他のコンテンツへの変更影響が大きいので嫌です。しかも、このディレクティブはApache2.0.31以降でしかサポートされません。

なぜここまで慎重になるのかって?turbogears.redirectの呼び出しすべてに完全URLを書けばいいんじゃないか?いえいえ、CherryPyでは、アプリケーションがHTTPRedirect例外を明示的に発生させてリダイレクトを返すほかにも、「末尾にスラッシュを持たなければならないリクエストがスラッシュを持たなかった」場合、末尾スラッシュを補ったURLへのリダイレクトが、(いっさいのアプリケーションコードを通らない段階で)自動的に発生するのです。CherryPyでは、自分のアプリケーション内に明示的なリダイレクトがないからといっても、Webブラウザのアドレス入力に自由にタイピングできる以上、意図しないリダイレクトは必ず起こるのです。

2007.11.7追記

ちなみに、server.webpathという「TurboGears拡張」を設定した場合、ログとHTTPレスポンスが矛盾するというバグを発見しました。ログでは404 Not Foundなのに、HTTPレスポンスは正常に返されます。これが発生する状況は、「server.webpath設定値がない場合に正しくアクセス可能なパスに対して、server.webpath設定値を設定した状態でアクセス」したときです。

例えば、 server.webpath なしで http://hoge:8080/testpage に応答するアプリケーションがあったとき、そのアプリケーションを server.webpath = "/myapp" と変更すると、以降は、http://hoge:8080/myapp/testpage としてアクセスするのが正しい用途ですが、誤ってその設定のままhttp://hoge:8080/testpageへアクセスしても、ブラウザには応答が返ってきます。でも、エラーログには404が残っています。

このバグのせいで、server.webpathの意味があいまいになっているようですが、どうやら実装者の意図は、「server.webpathを設定すると、それ以下のサブパスにしかアクセスできず、他のパスへのアクセスは404エラーになる」としたかったようです。たしかに、エラー応答をブラウザに返してくれないと、開発中にKidソースのリンクにtg_urlを忘れていた場合などに、ローカルのテストで気づく可能性がぐんと落ちます。Apacheのバックとしてデプロイして初めて、リンクミスが発覚します。

ログと画面の矛盾の原因は、「CherryPyAPITurboGearsが誤用している」ためでした。TurboGearsのソースで、/turbogears/startup.pyの132行目に、

def on_start_resource(self):

とあります。CherryPyにリクエストフィルタとして登録されるルーチンなのですが、CherryPyでは、このハンドラが例外を返すことを想定していません。HTTP関連の例外を受け取ってくれ、raise NotFound()が可能になるフェーズの最初のハンドラは、正しくは、before_request_bodyです。

というわけで、以下のパッチを/turbogears/startup.pyに当てたほうがよいでしょう。

132c132
<     def before_request_body(self):
---
>     def on_start_resource(self):

バグレポート
http://trac.turbogears.org/ticket/1606