前書き
1日調べた中での整理として書きますので、基本的には調べたことを継ぎ接ぎにして資料にします。
始まりは、プロキシとの戦いに疲れたのでgoで透過プロキシを作ってみた の記事を読んだことから。
面白い記事だなと思って見させてもらいつつ、理解するにあたって自分に足りない知識があったため、調べながら読み進めさせていただきました。ありがとうございます。
透過プロキシのモチベーション
プロキシ経由でないとアクセスできないネットワークがあり、ClientごとにProxyの設定をするのがめんどくさい。例えば、Firefoxでプロキシの設定をしたのでFirefoxからはアクセスできるが、safariではできない、curlでもアクセスできない。すごい不便だし、特定のネットワークへの通信はClientに関係なくプロキシに飛ばせたら楽だよね?というモチベーション。
透過プロキシの実現方法について
どうやら、以下のような手順で実現できるっぽい
- 透過プロキシ用のプログラムを書く
- tcpのportをlistenする
- iptables経由でtcpパケットがREDIRECTされてくるので、プロキシサーバへHTTP CONNECTパケットを送る
- この時すでに、Application:透過プロキシ ではTCP3WAYハンドシェイクを終えている
- HTTP CONNECT要求とその後のTCP転送のために、透過プロキシ:上位プロキシへTCPセッションを張る
- HTTP CONNECTのHOSTヘッダには、本来接続したかったIPアドレス、ポート番号を入れる*1
- HTTP CONNECTに対する正常レスポンスがプロキシサーバから返ってきたとき、すでにプロキシサーバは、接続したいHostとTCPコネクションが張れているので、Applicationから送られてくるTCPペイロードを、透過プロキシ:上位プロキシのTCPセッション(HTTP CONNECTリクエストを送るために使ったもの)
- 透過プロキシに飛ばしたいTCPパケットが必ず通るNetwork(or Server)機器に、TCPベースでRoutingルール(宛先IP、宛先PORT)を書いて、透過プロキシへパケットを転送するように設定する。(通常透過プロキシと上記のNW機器は同居させることが多く、Iptables+透過プロキシで実現される)
- 透過プロキシに転送するNetwork機器を同一サブネットに新たに経路に導入し、サブネット内のGWと別に構築する場合は、注意が必要。該当ApplicationへのInboud packetがGWからApplicationに直接届きますが、返りのパケットが透過プロキシに吸い込まれてしまうので、吸い込まれないように変更しないといけない。
- App -<tcpA>-> 透過 -<tcpB>->上位 で別々のtcpセッションを使うのであれば、そもそも非対称ルーティングではなく、透過プロキシがsynパケットをtrackできないので、App -<tcpZ>-> 透過へのtcpセッションが確立できなくてDropしてしまう。
- これについては、そもそもTCPのルーティングルールに下流のNetworkから発生したSynパケットとそのセッションから発生したパケットを透過プロキシに飛ばすようにしてあげればいいのかな?
- そう考えるとそのNW機器でセッション管理する必要がありますねー。
- App -<tcpA>-> 透過 -<tcpB>->上位 で別々のtcpセッションを使うのであれば、そもそも非対称ルーティングではなく、透過プロキシがsynパケットをtrackできないので、App -<tcpZ>-> 透過へのtcpセッションが確立できなくてDropしてしまう。
最初これを読んだ時は、「あーできそうだなー。」って思ってたんですが、ちょっとブレイクダウンするとなぜできるのかわからなくなったので、いろんな記事を巡ったり、考えてみたりしたので整理します。
TCPセッションをどこで終端させるのか
ここが曖昧なので、自分の中でうまく透過プロキシの実装のイメージがついてないので、整理します。
思いついたのは、2つのパターン。1つずつ見ていきます。
- App -<tcpA>-> 透過 -<tcpB>-> 上位 <- <tcpDEST> 宛先サーバ
- App ------------透過--<tcpZ>-> 上位 <- 宛先サーバ
App -<tcpA>-> 透過 -<tcpB>-> 上位 <- <tcpDEST> 宛先サーバ
パケットの状態は下記の通りに変化するのかな
- App が http://8.8.8.8:80 へアクセスを試みる(SYNパケットを送信)
- [[src IP: APP, dest IP: 8.8.8.8] sport: 5555, dport: 80 SYN] App -> NW
- NW機器が透過プロキシへ転送(dest IPとdest port変換)
- [[src IP: APP, dest IP: 透過] sport: 5555, dport: 3219 SYN] NW -> 透過
- 透過プロキシがAppへ、SYN-ACKを返信
- [[src IP: 透過, dest IP: APP] sport: 3219, dport: 5555 SYN,ACK] 透過 -> NW
- NW機器が2の時の逆変換し、Appへ転送
- [[src IP: 8.8.8.8, dest IP: APP] sport 80, dport 5555 SYN, ACK] NW -> APP
- Appが Ackを返信
- [[src IP: APP, dest IP: 8.8.8.8] sport 5555, dport 80 ACK] APP -> NW
- NW機器が透過プロキシへ転送(dest IPとdest port変換)
- [[src IP: APP, dest IP: 透過] sport: 5555, dport: 3219 ACK] NW -> 透過
- ここで App -<tcpA>-> 透過のTCPセッションが確立
- 透過プロキシが上位プロキシへHTTP CONNECTを送るために、SYNパケット送信
- 上位プロキシが透過プロキシへSYN ACK
- 透過プロキシが上位プロキシへACK
- ここで 透過 -<tcpB>-> 上位 のTCPセッションが確立
- Appが送信したかった元のリクエスト先(8.8.8.8, 80)を調べて、HTTP CONNECTのHOSTに指定し上位プロキシへHTTP request
- 上位プロキシが、HTTP ConnectリクエストからHost情報を読み取り、上位とリクエスト先でTCPセッションを確立する
- 上位 - リクエスト先でセッションが確立できたら、HTTP Connectリクエストに対するHTTP responseを透過プロキシへ返送
- この段階で、全てのTCPセッションが確立される
- App -<tcpA>-> 透過 -<tcpB>-> 上位 <- <tcpDEST> 宛先サーバ
- AppがTCPのペイロード(HTTP GETとかなんでも)を8.8.8.8:80へ送信
- NW機器が透過プロキシへ転送(dest IPとdest port変換)
- 透過プロキシでTCPのペイロードを取り出し、-<tcpB>-> のセッション用のtcp packetを作成し、上位プロキシへ送信
- 上位プロキシでTCPのペイロードを取り出し、-<tcpDEST>-> のセッション用のtcp packetを作成し、宛先サーバへ送信 .....
という感じだろうか。ここで、いくつか実装に疑問がある
- NW機器でどのように透過プロキシアプリケーションへRedirectするのか?
- IP/TCPヘッダを書き換えてしまうのか?
- IP[TCP[IP[TCP]]]のようにTCPパケットでカプセリングして送信するのか?
- macアドレスのみ透過プロキシのアプリケーションが動くhostのnicに変更して、該当hostに送りつけるのか(tcpソケットを使ってる場合ipパケットの処理をする部分でkernelによってDropされるので、raw socketか何かで、l2パケットを直接アプリケーションに読み込ませないといけない)
- 透過アプリケーションでどのように、オリジナルのリクエスト先を取得するのか?
- もちろん、http限定の透過プロキシと固定してしまえば、L4以上のレイヤで接続先情報が取れる(Http request headerのHostフィールド)ので問題ないかもしれないが、tcp用透過プロキシと汎用的に作ることはこれではできない
正直いうと、tcp socketを使ったアプリケーションでは、オリジナルリクエスト先を取得することは、できないんじゃないかとずっと思っていたので、透過プロキシを作ることは無理なんじゃないかと思ってた。
ただ、redsocks や github.com/wadahiro/go-transproxy という実装があるので、おそらく僕は何かを見落としているのだろう。
App -<tcpZ>-> 透過 -<tcpZ>-> 上位 <- 宛先サーバ
これは、少しトリッキーな気がする。というかLinuxでの実装が可能かどうかさっぱりイメージがつかないので、少しざっくり処理の流れを書いてみる
- App が http://8.8.8.8:80 へアクセスを試みる(SYNパケットを送信)
- [[src IP: APP, dest IP: 8.8.8.8] sport: 5555, dport: 80 SYN] App -> NW
- NW機器がIP/TCPヘッダを書き換えて、透過プロキシへ転送する
- 宛先を8.8.8.8から透過プロキシへ書き換え
- 透過プロキシでまた、IP/TCPヘッダを書き換えて、上位プロキシへ転送する
- 送信元をAPPから透過プロキシ、宛先を透過プロキシから上位プロキシへ書き換え
- この時に、オリジナルのリクエスト先 8.8.8.8:80 を保存しておく
- 上位プロキシへSYNパケットが届く
- 上位プロキシから透過プロキシへSYN ACKが送信される
- 透過プロキシからNW機器へSYN ACKを転送
- 送信元を上位プロキシから透過プロキシへ、宛先を透過プロキシからAppへ書き換え
- NW機器からAppへSYN ACKを転送
- 送信元を透過プロキシから8.8.8.8へ書き換え
- AppからAckを8.8.8.8へ送信.....でそんなこんなでapp ---> 上位へのtcpセッションが確立される
- App -<tcpZ>-> 透過 -<tcpZ>-> 上位
- Appからtcpデータ(httpパケット)が8.8.8.8へ送信を試みる
- NW機器がIP/TCPヘッダを書き換えて、透過プロキシへ転送する
- 透過プロキシでは、tcpのデータを一度、メモリへ退避
- 先ほど確立したセッションを使って、透過プロキシから上位プロキシへHTTP Connectをこのタイミングで送信する
- 上位プロキシが....
あー、これはできませんね。Appで管理しているTCPのシーケンス番号と上位プロキシ側で管理するシーケンス番号に乖離が起きるので、成り立たないです。
Appを暗黙的に上位プロキシとTCPセッション貼らせて、TCPのデータを送る直前にHTTP Connectのパケットを上位プロキシにTCPデータとして送ることができれば、そのHTTP request以降のtcpのデータは単純に上位から宛先まで転送できるので、いけるかと思いましたが。
言われ見れば、当たり前ですが、特定のTCPセッションをインターセプトして、データを横から注入なんてことはできませんね。
なので、tcpの透過プロキシを作るとしたら、tcpセッションApp -<tcpA>-> 透過 -<tcpB>-> 上位 <- <tcpDEST> 宛先サーバ のようなセッションの張り方にする必要がありそうです。
非対称ルーティング
透過プロキシにおいては、インバウンド通信時の非対称ルーティングの扱いに気をつけないといけない。
なぜこの非対称ルーティングが問題になることがあるかというと、それはFWなどで、TCPセッションを監視しそれをベースにアクセス制御をしている場合(ステートフルインスペクション)である。
どういうことかというと、下流ネットワークからSynパケットが出ていないTCPセッションに対して、上流からのSyn-Ackパケットは通すべきじゃないのでDrop。EstablishedしていないTCPセッションに対してAckパケットを通すべきじゃないのでDropなど。
しかし、透過プロキシにおける問題は、この手の非対称ルーティングで起こるよくある問題ではない。自分の理解では下記のようにTCPセッションが確立されるので
- App -(NW装置がパケットを曲げる)-<tcpA>-> 透過 -<tcpB>-> 上位 <- 宛先サーバ
インバウンドの通信は、透過プロキシに通さない or 透過プロキシは何もせずにGWに返さないといけないと通信できない。なぜかというと、インバウンドの通信については、送信元からGW、GWから直接AppへSynパケットが飛び、AppはSyn-Ackを返送するがこのSyn-Ackを透過プロキシに曲げてしまうと、透過プロキシはSynパケットをAppに送信してないので、見知らぬクライアントからのSyn-Ackはもちろん無視する。
この際の対策としては、パケットを曲げるNW装置で透過プロキシに曲げるパケットの条件をアウトバウンドへ確立したTCPセッションのみとするなどしないとして、特定のパケットのみ飛ばすようにすることがあげられる。
ICMPリダイレクト
1つのサブネットにGWが複数用意されている場合などにお見かけすることがあるんじゃないだろうか。
例えば、透過プロキシへ曲げるGWAとインターネットにルーティングするGWBの2つがあるサブネットで、透過プロキシを使うClientはGWAを曲げないものは、GWBをとした時。GWAを使うクライアントがインターネットにつなぐ時は、下記のようになる
- Client -> GWA -> GWB ---(internet) ---> Server
この状態でClientから、ping 8.8.8.8 とすると。おそらくGWAがGWBへルーティングしたあとClientへICMPリダイレクトを送り、次のリクエストからGWBを使ってね。こうすることで、次回からClientは1hop少なく8.8.8.8へアクセスすることができる。
透過用プロキシを利用する場合は、勝手にGWBを直接使うようになってしまっては困るので、ICMPリダイレクトはオフにすべきである
Linuxでの透過プロキシを実装
透過プロキシを実装する上で必要なものが2つ
- Clientからのパケットを透過プロキシへパケットを曲げるGW
- 透過プロキシの実装
で、1,2を別のServerで実装したいという強い希望があると、ちょっと大変である。該当GWと透過プロキシ間でoriginal destinationを共有する術を考えないといけない。例えば
- tcpでカプセリングして、透過プロキシにパケットを転送する
- 該当GWと透過プロキシ間でAPIみたいな形で通信して、やりとりをする
両機能を1つのHostで同居させていい場合は、実はそんなに難しくない
- Clientからのパケットを透過プロキシへパケットを曲げるGW
- iptablesのREDIRECTターゲットを利用して、宛先を自IPの特定のportへ変換する
- iptables(netfilter)の層でpacketを書き換えてから、ip,tcpプロトコルの処理が別れて行われるのではなく、
- 何が言いたいかというと、最初の僕の理解は、
- packetAのカーネルのipプロトコル処理プログラムの各ポイントでnetfilterが実行されて、packetが書き換えられたりし、netfliter評価後のパケットを作る
- netfilter評価後のパケットをip/tcpのプロトコル処理プログラムで評価し、ポートにbindされているprocessに渡されたり(socketの作成)、ルーティングされて別のhostに転送されたりする
- 上記の理解だと、元のデータは取得できないので、おそらくnetfilterとip/tcpプロトコルの処理プログラムはもう少し厳密に、絡み合っていてREDIRECTターゲットが指定されるとそのままsocketが作られて、socketのオプションに元の宛先が格納されたりするのだろう
- 何が言いたいかというと、最初の僕の理解は、
- 透過プロキシの実装
- socketのSO_ORIGINAL_DSTで元の宛先が取得できることだけ覚えておけば、そこまで困難なことはないと思う
- あとは、tcp socketを開いて特定のportで待ち受けておいて、
- リクエストがiptablesによってREDIRECTされてきたら、
- socket.getsockoptとかでSO_ORIGINAL_DSTの元の宛先を取得を試み
- 元の宛先を取得したら、上位プロキシへHTTP Connectリクエストを送る。
- HTTP Connectリクエストを送る時に作ったtcp socketを使い、その後clientから送られてくるtcp dataをそのまま上位プロキシ宛に流す
うん、なんだかできそうだ。
考察
SO_ORIGINAL_DSTというオプションを知らなかったこととiptablesのREDIRECTについて勘違いしていたので、実装がイマイチわかっていませんでしたがその2つが解消されて、スッキリしました。
思考過程を「透過プロキシの実現方法について」や「TCPセッションをどこで終端させるのか」に羅列しています。それを書いているときは、まだ上記2つにたどり着いていなかったので結構あやふやなことが書いてありますが、思考の整理にはなりましたのでそのまま放置しておきます。