Tuesday, February 5, 2008

UDP Hole Punching

仕事でUDPのデータストリームを扱っていて、さらにクライアントはNAT超えを考慮しなくてはならないということで、UDPのNAT超えでよく使われる手法、UDP Hole Punching (UHP) というものを実装することになった。

UDP Hole Punchingというのはルータに動的に空けられたNATテーブルの穴を逆から叩くことにより、NATの中のクライアントとUDP通信を可能にする技術であるからして。

さて、TCPのコントロールコネクションはもともとあるので、そのプロトコル上に UHP のネゴシエーションを付け加える形にした。

1. TCP C: UHPInitialize <NOP>
2. TCP S: UHPInitializeOK <SESSION ID> <SERVER UDP PORT>
3. UDP C: UHPRequest <SESSION ID>
4. UDP S: UHPRequestOK <NOP>
5. TCP C: UHPComplete <SUCCESS | FAILED>
6. TCP S: UHPCompleteOK <NOP>

3と4で UDP Hole Punching を行うわけだが、UDPなのでロストするかもしれない。なので両者とも数回再送を試みる。
最終的に、クライアントが4の UHPRequestOK を受け取る事ができればUHPは成功であり、
5 の UHPComplete で SUCCESS を返すわけである。

3もしくは4をロストしたり、そもそも UHP が不可能な NAT 環境であった場合、FAILEDを返すことにより TCP へ Fallback するなりなんなりするようにする。といった寸法である。

以上のシーケンスをRubyでテストコード書いて(仕事のコードなので公開はできないが)実行してみたのだが、うまくいかない。
NAT なしの LAN内であれば動くのでアルゴリズム的な部分は間違ってない(と、思う)。

ルータが特殊なのかと思っていたのだが、帰宅途中で罠に気がついた。

実は、テストしているサーバは複数の Network Interface をもっている。
アドレスがプライベートで NAT の中にある eth0 と、実験でつかっている(つもり)のグローバルアドレスを持つ eth1 であり、default route は eth0 の NATルータである。

そして、サーバでudp socketがbind() しているアドレスは '0.0.0.0' である。
つまり、クライアントがグローバルアドレスに sendto()し、サーバが recvfrom() により取得したした client socket address へ sendto() を返す場合、サーバの source addres はカーネルが勝手に決めているのであり、基本的に default routeが使われるわけだから、eth0のプライベートアドレスになってしまう。
要するにサーバもNAT内あるものとして動作をしていた、というわけなのである。
UHP は NAT(client)からNAT(server) では仲介サーバが居ない限り不可能である。

今回はそういったケースは考慮しないので、素直にサーバの bind address をグローバルなものにして再実験をしたところ、うまく動作した。

教訓。udp socket を '0.0.0.0' に bind()してlistenするときは複数の interface や routing がないか注意しよう。

No comments: