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

CloudFormation Custom Resourceの意図しない削除を防ぐ

最近はCloudFormationをよく使っています。大変便利なのですが、S3オブジェクトやDynamoDBのItemの作成には対応していないため、Lambda-backed Custom Resourceで作成しています。ただCustom Resourceのライフサイクルは少し独特なところがあって、理解せずに使うと、意図せずResourceが削除されてしまうことがあります(あった)。

意図しない削除を起こさないようにするため、この記事ではいくつかの状況についてCustom Resourceがいつ作られ、いつ更新され、いつ削除されるのかまとめます。Lambda-backed Custom Resourceを想定していますが、SNS-backedでも基本は同じではないかと思います。

PhysicalResourceIDについて

まずは前提として、PhysicalResourceIDについて説明します。PhysicalResourceIDは、Custom Resourceから呼ばれるLambda関数がレスポンスに設定する値の一つです。レスポンスについて説明したドキュメントには以下のように書かれています。

This value should be an identifier unique to the custom resource vendor,
and can be up to 1 Kb in size. The value must be a non-empty string and
must be identical for all responses for the same resource. 

最後のmust be identical for all responses for the same resourceが重要です。ここの意味するところは、Custom Resourceのドキュメントに以下のようにはっきり書かれています。

When AWS CloudFormation receives the response, it compares the
PhysicalResourceId between the old and new custom resources.
If they are different, AWS CloudFormation recognizes the update
as a replacement and sends a delete request to the old resource.

要はPhysicalResourceIDが変わったら古いResourceは削除される、ということですね。

前述のように、PhysicalResourceIDはLambda関数がレスポンスの中に含める値です。レスポンスは、Node.jsの場合はcfn-responseモジュールのsendメソッドで送ることが多いかと思います。PhysicalResourceIDはsendメソッドの5つ目の引数で指定できます。例えば以下のような感じです。

var response = require('cfn-response');
exports.handler = function(event, context) {
  response.send(event, context, response.SUCCESS, {}, 'PhysicalResourceID');
};

cfn-responseモジュールのドキュメントを見ると、PhysicalResourceIDについては以下のように書かれています。

Optional. The unique identifier of the custom resource that invoked
the function. By default, the module uses the name of the Amazon
CloudWatch Logs log stream that is associated with the Lambda function.

PhysicalResourceIDの指定を省略するとLog stream名が使われるとのことです。これは例えば2017/05/13/[$LATEST]2582d4dedea542868953b34db229dd35のような値で、Lambda関数を更新するたびに変わっているようです。

ここまでのポイントとしては以下です。

  • PhysicalResourceIDはCustom ResourceのIDで、Lambda関数のレスポンスに含めることで指定できる。
  • このIDが変わると、古いCustom Resourceが削除される。
  • PhysicalResourceIDを指定しない場合、Lambda関数が更新されるたびに異なるPhysicalResourceIDが設定される。

ここからは、いくつかの状況について、Custom Resourceがいつ作られ、更新され、削除されるのか見ていきます。

PhysicalResourceIDを指定しない場合

以下のようなPhysicalResourceIDを指定しない場合を考えます。CloudFormationのTemplateはこちらから参照できます。

var response = require('cfn-response');
exports.handler = function(event, context) {
  response.send(event, context, response.SUCCESS, {});
};

まずはスタックの作成をします。以下はCloudFormationのEventsタブのスクショです。

f:id:ks888:20170513133719p:plain

Custom Resrouceが作成されています。

次にスタックの更新をします。何か変更しないと更新できないので、スタックに与えるパラメータを変えています。

f:id:ks888:20170513132222p:plain

Custom Resourceが更新されています。

ここでLambda関数を更新してみます。今回はLambda関数に割り当てるメモリサイズをAWSコンソールから変更しました。再びスタックの更新をします。

f:id:ks888:20170513133729p:plain

Custom Resourceが更新されているのに加え、最後に同Resourceが削除されています。これは、Lambda関数の更新に伴ってPhysicalResourceIDが更新されたからです。

もう一度スタックの更新をしてみます。

f:id:ks888:20170513132248p:plain

Custom Resourceが更新されていますが、Physical IDが変わっています。

最後にスタックを削除します。

f:id:ks888:20170513132257p:plain

Custom Resourceが削除されました。

作成から更新、削除までを一通りみました。PhysicalResourceIDを指定しない場合に重要なのは以下の2点かと思います。

  • Lambda関数を更新するとPhysicalResourceIDが変わるため、古いCustom Resourceが削除される。
  • 古いCustom Resourceが削除されても、新しいCustom ResourceのCreateは呼ばれない。

特に2点目はやや意外な挙動な気がするので、ID指定をしない場合は注意したいところです。

PhysicalResourceIDを指定する場合

以下のようにPhysicalResourceIDを指定する場合を考えます。Templateはこちらから参照できます。

var response = require('cfn-response');
exports.handler = function(event, context) {
  response.send(event, context, response.SUCCESS, {}, 'PhysicalResourceID');
};

まずはスタックの作成をします。

f:id:ks888:20170513133741p:plain

Custom Resrouceが作成されています。

次にLambda関数を更新してみた上で、スタックを更新します。

f:id:ks888:20170513132314p:plain

Custom Resourceが更新されています。今度はResourceは削除されていません。

最後にスタックを削除します。

f:id:ks888:20170513132325p:plain

Custom Resourceが削除されました。

シンプルですね。PhysicalResourceIDを指定しておけば、Lambda関数が更新されてもCustom Resourceは削除されません。

まとめ

PhysicalResourceIDを指定しない場合と指定する場合について、Custom Resourceがいつ作成・更新・削除されるかまとめました。Resourceの意図しない削除を防ぐには、PhysicalResourceIDを常に同じ値にしておくのがよさそうです。他にも削除されるケースをご存知でしたら、ぜひ教えてください。

mitmproxyでHTTPSプロキシする時、どんな通信が発生しているのか追ってみる

前回の記事で、ブラウザからアクセスするURLはそのままで接続先サーバだけ変える方法と、その際のHTTP通信の様子を確認しました。今回はその続きで、HTTPSの場合について、ブラウザとmitmproxy間、mitmproxyとサーバ間の通信内容を追っていきたいと思います。

処理の概要

HTTPSプロキシする場合のmitmproxyの挙動について書かれたドキュメントがあるので、そちらを参考にして概要を掴みます。以下はそのドキュメントから引用した図です。

f:id:ks888:20170504194435p:plain

この図に基づいて、順番に説明していきます。

1-2: ブラウザとプロキシ間でHTTP CONNECT

最初にHTTP CONNECTでブラウザとプロキシ間をつなぎます。通常のプロキシの場合はここでプロキシ先サーバとのTCP接続(HTTPトンネル)を確立するのですが、mitmproxyはしません。

mitmproxyはHTTPSリクエストの確認・変更に対応するため、ブラウザとプロキシ間、プロキシとサーバ間それぞれでTLS接続をします。そのため、ここでHTTPトンネルを作る必要はありません。

3,6: ブラウザとプロキシ間でTLS接続確立

次にブラウザとプロキシ間でTLSハンドシェイクをします。あとでWiresharkで確認しますが、3でClient Hello、6で残りのやりとりをしています。4-5ではプロキシとサーバ間でTLSハンドシェイクをしています。

なぜこのような順序なのかというと、mitmproxyが独自のサーバ証明書を作成する際、証明書に紐付けるドメイン名を明らかにするためです。この情報は、本物のサーバ証明書Common NameSubjectAltNameという箇所に書かれています。今回初めて知ったのですが、サーバ証明書には複数のドメイン名を紐付けられるらしいです。実際www.google.co.jpにアクセスしたときのサーバ証明書を見てみると、Common Name*.google.comで、SubjectAltNameは以下のようなドメイン名の列になっています。*.google.co.jpyoutube.comもここにあります。

f:id:ks888:20170504194946p:plain:w500

これらの情報を取得してからサーバ証明書を作るため、このような実行順序になっているようです。

4-5: プロキシとサーバ間でTLS接続確立

3,6で書いたような目的のために、ここでプロキシとサーバ間でTLS接続を確立します。図にはTLSハンドシェイクをどこまで進めるのか書かれていませんが、実際の挙動としては最後まで進めているようです。

7-8: HTTPSリクエス

ここまででブラウザとプロキシ間、プロキシとサーバ間それぞれでTLS接続が確立されたので、あとはいつもどおりリクエストを送るだけです。ただし、前回の記事に書いたような方法で接続先サーバを変える場合、プロキシとサーバ間ではもう一度TLS接続が必要になります。接続先変更後のサーバとはTLS接続していないためです。

実際の通信をみてみる

次にWiresharkで実際の通信をみてみます。

今回はブラウザからhttps://example.com/にアクセスすると、実際はhttps://www.google.co.jpにアクセスするようにしてみました。mitmproxyは以下のように実行しました。プロキシのポートは8080番です。その他の設定については前回の記事を参考にしてください。

mitmproxy --no-http2 -s "./mitmproxy_replace_host.py example.com www.google.co.jp"

ブラウザからhttps://example.com/にアクセスすると、TLSとHTTPのレベルでは以下のようなパケットが飛んでいました。

f:id:ks888:20170504200011p:plain

大体ドキュメント通りに並んでいます。8で再びTLS接続をしていますが、これはスクリプトがリクエストを書き換えているためです。4-5ではhttps://example.com/TLS接続したのに対し、8ではhttps://www.google.co.jpTLS接続しています。

まとめ

HTTPSの場合について、ブラウザとmitmproxy間、mitmproxyとサーバ間の通信内容を確認しました。mitmproxy組込みのCA証明書を使うために、通常のHTTPSプロキシとは異なる手順を踏んでいることがわかりました。

参考にしたページ: http://docs.mitmproxy.org/en/stable/howmitmproxy.html

mitmproxyを使って、アクセスするURLはそのままで接続先サーバだけ変える

最近App Engineでの開発中に、ブラウザからはexample-a.comというURLでアクセスしたいけど、実際にはexample-b.comのサーバに接続させたい、ということがありました。こういうとき、mitmproxyというツールを使うと楽だったので、やり方をメモしておきます。ついでに、mitmproxyをプロキシとして使う際の通信の様子をちょっと追ってみます。

mitmproxyについて

mitmproxyはHTTPプロキシの一種で、これをブラウザのプロキシに設定しておけば、HTTPトラフィックの確認や変更が手軽にできます。特に、mitmproxy組込みのCA証明書をインストールしておけばHTTPS通信も簡単に変更できちゃうところが良いです。

今回は、ブラウザからはexample-a.comドメインにアクセスしているように見えるけど、実際はexample-b.comにアクセスしている、という状況を作ります。ブラウザ、mitmproxy、サーバは、以下のような働きをします。

  • ブラウザ: mitmproxyをプロキシとしてHTTPリクエストを出す
  • mitmproxy: HTTPリクエストを書き換え、ブラウザが指定したサーバとは異なるサーバへHTTPリクエストを出す
  • サーバ: 特に変更なし

準備

  1. ブラウザのProxy設定

    mitmproxyはデフォルトで8080番ポートをLISTENしますので、ブラウザのProxy設定を127.0.0.1:8080にします。

  2. mitmproxyの準備

    mitmproxyを http://docs.mitmproxy.org/en/stable/install.html にある手順に沿ってインストールします。 MacOSの場合はbrewで一発です。

    brew install mitmproxy

    次に、proxy組込みのCA証明書を http://docs.mitmproxy.org/en/stable/certinstall.html にある手順に沿ってインストールします。

  3. HTTPリクエスト/レスポンスを書き換えるスクリプトを作成

    以下のスクリプトをダウンロードしておきます。

    Replacerクラスのrequestメソッドで、送られてきたHTTPリクエストのターゲットとHost Headerを書き換えています。また、リダイレクトが返ってきてもある程度はうまく動くようにHTTPレスポンスのLocationも書き換えています。

リクエストを書き換えてみる

実際にHTTPリクエストを書き換えてみます。まずは以下のようにしてmitmproxyを実行します。[src domain name]にブラウザからアクセスするドメイン名、[dst domain name]に実際に接続させたいサーバのドメイン名を指定します。

mitmproxy --no-http2 -s "./mitmproxy_replace_host.py [src domain name] [dst domain name]"

--no-http2オプションを指定しているのは、HTTP2のリクエストだと、HTTPリクエストのターゲットを書き換える箇所でエラーが出るためです。

この状態でブラウザからサイトにアクセスすると、[dst domain name]で指定したサーバの方にリクエストが飛んでいることが確認できるかと思います。

mitmproxyはどんな通信しているのか?

ここまでで目的は果たせましたが、せっかくなのでもう少し掘ってみます。具体的には以下の2点です。

  • HTTPの場合、mitmproxyはブラウザ、サーバとどんな通信をするのか?
  • HTTPSの場合はどうなのか?

まずはHTTPの場合です。ここの通信の様子はmitmproxy自身では確認できないと思うので、Wiresharkで見てみます。

今回はブラウザからhttp://ks888.hatenablog.com/にアクセスすると、実際はhttp://localhost/にアクセスするようにしてみました。localhostの80番でモックサーバーを起動して、mitmproxyを以下のように実行しました。

mitmproxy --no-http2 -s "./mitmproxy_replace_host.py ks888.hatenablog.com localhost"

ブラウザからhttp://ks888.hatenablog.com/にアクセスすると、HTTPレベルでは以下のようなパケットが飛んでいました。

f:id:ks888:20170504192518p:plain

赤枠の箇所にHTTPリクエスト/レスポンスの一行目が表示されています。ブラウザからプロキシへのHTTPリクエストは以下のようになっています。

GET http://ks888.hatenablog.com/ HTTP/1.1

注目したいのはhttp://ks888.hatenablog.com/で、いつもはURLのパス部分だけ指定されている箇所にプロトコルドメイン名まで指定されています。これはブラウザがプロキシ宛にHTTPリクエストを出す際、本当に接続したいサーバー(=オリジンサーバー)を指定する方法です。HTTP/1.1のRFCにもこのあたりに記載されています。

今回のスクリプトではここの値を他の値にすることで、リクエストをブラウザで指定したものとは異なるサーバに飛ばしています。

プロキシからサーバへのHTTPリクエストは以下のようになっています。特に気になるところはないです。

GET / HTTP/1.1 

HTTPレスポンスについても特に気になるところはありませんでした。

簡単ですがHTTPの場合を見てみました。次にHTTPSの場合を見ていきますが、長くなってきたので次回の記事に回したいと思います。TLSハンドシェイクをブラウザ<->プロキシ間とプロキシ<->サーバー間でやるだけでは?と思ってましたが、実際にはもう一工夫必要そうでした。

追記:続きの記事を書きました。

まとめ

mitmproxyを使って、ブラウザからはexample-a.comドメインにアクセスしているように見えるけど、実際はexample-b.comにアクセスしている状況を作りました。また、HTTPの場合にmitmproxyはどんな通信をするのか見てみました。

参考にしたページ: http://docs.mitmproxy.org/en/stable/howmitmproxy.html