リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC
WebSocket サーバーをバイトから作る
目標
RFC 6455のハンドシェイク・フレーム・マスキング・制御フレーム・クローズコードを、標準ライブラリだけで実装し、実際のライブラリのクライアントと接続して、規則を守っているかを確認します。
なぜ重要なのか
WebSocketの障害記録には、たいていクローズコード1行だけが残ります。1002と1006と1009が何を意味するのか、誰がどの規則を破るとそのコードが出るのかを知らなければ、その1行を読めません。プロキシの後ろでだけ接続が切れたり、大きなメッセージでだけ切れたりする問題も、結局は、このバイト列の問題です。ライブラリが代わりにしてくれていたことを一度自分の手でやっておけば、次からは、ライブラリのエラーメッセージが何を言っているのか、読めるようになります。
ステップ
- ハンドシェイクのキーを計算する: /root/rt/ws/wsproto.pyにaccept_key(key)を作ってください。クライアントが送ったSec-WebSocket-Key文字列の後ろに258EAFA5-E914-47DA-95CA-C5AB0DC85B11を付けてSHA-1でハッシュし、その20バイトをbase64でエンコードした文字列を返します。
- フレームを作る: /root/rt/ws/wsproto.pyにencode_frame(opcode, payload, fin=True, mask_key=None)を追加してください。最初のバイトはFINビットとopcode、2番目のバイトはMASKビットと長さです。長さが125以下ならそのまま、65535以下なら126のあとに2バイト、それより大きければ127のあとに8バイトを、ビッグエンディアンで書きます。mask_keyで4バイトが来たら、MASKビットを立てて、長さのあとにその4バイトを書き、本文のi番目のバイトをmask_key[i % 4]とXORして付けます。
- フレームを読み、規則を破ったものを見分ける: /root/rt/ws/wsproto.pyにProtocolError(code)例外とdecode_frame(buf)を追加してください。bufにフレーム1つがまだ全部届いていなければNone、全部届いていれば(fin bool, opcode int, マスクを外した本文bytes, masked bool, 使ったバイト数)を返します。RSVビットが立っている、定義されていないopcode(3から7まで、11から15まで)である、制御フレーム(8・9・10)の本文が125バイトを超える、またはFINが立っていない場合は、ProtocolError(1002)を出します。ProtocolErrorは、クローズコードをcode属性として持っている必要があります。
- HTTPリクエストを101に変える: /root/rt/ws/wsproto.pyにhandshake_response(request)を追加してください。requestは、空行までのHTTPリクエストbytesです。GETで、Upgradeがwebsocketで、ConnectionにUpgradeトークンがあり、Sec-WebSocket-Versionが13で、Sec-WebSocket-Keyがあれば、"HTTP/1.1 101 Switching Protocols"と、Upgrade・Connection・Sec-WebSocket-Acceptヘッダーを含む応答bytesを返します。バージョンだけが違えば、426にSec-WebSocket-Version: 13ヘッダーを付けて、それ以外の不備は400を返します。ヘッダー名とwebsocket・upgradeの値は、大文字と小文字を区別しません。
- サーバーを立ててエコーを返す: /root/rt/ws/wsproto.pyにserve(host, port, max_size=1048576)を追加してください。接続ごとに1つのスレッドでハンドシェイクを行い、101でなければ応答を送ってから閉じます。そのあとは、フレームを読んで、テキスト・バイナリメッセージを、同じopcodeで返します。断片化されたメッセージ(FIN 0と継続フレーム0)は、集めて1つにして返し、断片の間に割り込んだpingには、同じ本文のpongですぐに答えます。サーバーが送るフレームは、マスキングしません。
- 規則を破った相手を適切なコードで閉じる: serveを直して、クローズを処理してください。相手のcloseフレームにコードがあれば同じコードで、なければ空の本文でcloseを送り返してから、TCPを閉じます。マスキングされていないクライアントフレームは1002、UTF-8ではないテキストメッセージは1007、集めたメッセージがmax_sizeバイトを超えたら1009で、closeを送って閉じます。decode_frameがProtocolErrorを出したら、そのcodeで閉じます。
- 本物のクライアントとつないでみる: 追加で作るものはありません。採点ツールがwebsocketsライブラリのクライアントで自分のサーバーに接続し、ハングルのテキスト、70000バイトのバイナリ(8バイトの長さを使うサイズ)、pingを送って、1000で閉じます。ライブラリがプロトコル違反として接続を切らず、すべてのエコーとpong、クローズコード1000を受け取れば合格です。
参考
- 作業フォルダは/root/rt/wsです。mkdir -p /root/rt/wsで、先に作ってください。
- サーバーを自分で起動してみるには、/opt/rt-lab/bin/python -c "import sys; sys.path.insert(0, '/root/rt/ws'); import wsproto; wsproto.serve('127.0.0.1', 9001)"を使ってください。採点ツールは、空いているポートを選んで、別に起動します。
- serveは、終わらない関数です。採点ツールは、サーバーを別プロセスで起動して、採点が終わったら停止します。
- よくあるミスが2つあります。サーバーフレームまでマスキングすることと、1回のrecvでフレーム1つが届くと仮定して、断片に分かれて届いたフレームを見逃すことです。
- Pythonは、必ず/opt/rt-lab/bin/pythonで実行します。このラボのライブラリは、その仮想環境にだけ入っていて、普通のpython3で実行すると、ModuleNotFoundErrorが出ます。alias rpy=/opt/rt-lab/bin/pythonのように短くしておくと便利です。
- ラボのPodは、外へ出る接続が塞がれています。すべての通信は、同じPodの中の127.0.0.1で行われ、インストールやダウンロードは必要ありません。
- 採点ツールは、コードを別プロセスで読み込んで、実際に接続を張ってみます。例のファイルは関数の枠にすぎないので、そのままでは合格しません。前のステップで完成させた関数は、消さないでください。
- ラボのセッションが終わると、/rootのファイルは残りません。必要なコードは、終える前に別に保管してください。
ハンドシェイクのキーを計算する
/root/rt/ws/wsproto.pyにaccept_key(key)を作ってください。クライアントが送ったSec-WebSocket-Key文字列の後ろに258EAFA5-E914-47DA-95CA-C5AB0DC85B11を付けてSHA-1でハッシュし、その20バイトをbase64でエンコードした文字列を返します。
RFC 6455の1.3節の例のキーdGhlIHNhbXBsZSBub25jZQ==は、s3pPLMBiTxaQ9kYGzzhZRbK+xOo=になるはずです。キーをbase64でデコードせずに、文字列のまま付けるのが核心です。
フレームを作る
/root/rt/ws/wsproto.pyにencode_frame(opcode, payload, fin=True, mask_key=None)を追加してください。最初のバイトはFINビットとopcode、2番目のバイトはMASKビットと長さです。長さが125以下ならそのまま、65535以下なら126のあとに2バイト、それより大きければ127のあとに8バイトを、ビッグエンディアンで書きます。mask_keyで4バイトが来たら、MASKビットを立てて、長さのあとにその4バイトを書き、本文のi番目のバイトをmask_key[i % 4]とXORして付けます。
マスキングは暗号化ではありません。キーがフレームにそのまま載って運ばれます。目的は、中間キャッシュがWebSocketのバイトをHTTP応答と誤解するようにしむける攻撃を防ぐことです(RFC 6455の10.3節)。
フレームを読み、規則を破ったものを見分ける
/root/rt/ws/wsproto.pyにProtocolError(code)例外とdecode_frame(buf)を追加してください。bufにフレーム1つがまだ全部届いていなければNone、全部届いていれば(fin bool, opcode int, マスクを外した本文bytes, masked bool, 使ったバイト数)を返します。RSVビットが立っている、定義されていないopcode(3から7まで、11から15まで)である、制御フレーム(8・9・10)の本文が125バイトを超える、またはFINが立っていない場合は、ProtocolError(1002)を出します。ProtocolErrorは、クローズコードをcode属性として持っている必要があります。
ストリームから読むので、一度にフレームが全部届くという保証はありません。ヘッダー2バイト→拡張長→マスクキー→本文の順に、それぞれの位置までバイトが足りなければNoneです。使ったバイト数を返してはじめて、呼び出し側が次のフレームを続けて読めます。
HTTPリクエストを101に変える
/root/rt/ws/wsproto.pyにhandshake_response(request)を追加してください。requestは、空行までのHTTPリクエストbytesです。GETで、Upgradeがwebsocketで、ConnectionにUpgradeトークンがあり、Sec-WebSocket-Versionが13で、Sec-WebSocket-Keyがあれば、"HTTP/1.1 101 Switching Protocols"と、Upgrade・Connection・Sec-WebSocket-Acceptヘッダーを含む応答bytesを返します。バージョンだけが違えば、426にSec-WebSocket-Version: 13ヘッダーを付けて、それ以外の不備は400を返します。ヘッダー名とwebsocket・upgradeの値は、大文字と小文字を区別しません。
Connectionヘッダーには、"keep-alive, Upgrade"のように、トークンが複数来ることがあります。カンマで分けて1つずつ見てください。426にサポートするバージョンを書いておけば、クライアントは何で再試行すればよいかがわかります。
サーバーを立ててエコーを返す
/root/rt/ws/wsproto.pyにserve(host, port, max_size=1048576)を追加してください。接続ごとに1つのスレッドでハンドシェイクを行い、101でなければ応答を送ってから閉じます。そのあとは、フレームを読んで、テキスト・バイナリメッセージを、同じopcodeで返します。断片化されたメッセージ(FIN 0と継続フレーム0)は、集めて1つにして返し、断片の間に割り込んだpingには、同じ本文のpongですぐに答えます。サーバーが送るフレームは、マスキングしません。
制御フレームは、断片化されたメッセージのまっただ中に割り込めます(RFC 6455の5.4節)。断片を集めるバッファーと、制御フレームの処理を、別に持ってください。サーバーがマスキングしたら、規則を守るクライアントは接続を切る必要があります。
規則を破った相手を適切なコードで閉じる
serveを直して、クローズを処理してください。相手のcloseフレームにコードがあれば同じコードで、なければ空の本文でcloseを送り返してから、TCPを閉じます。マスキングされていないクライアントフレームは1002、UTF-8ではないテキストメッセージは1007、集めたメッセージがmax_sizeバイトを超えたら1009で、closeを送って閉じます。decode_frameがProtocolErrorを出したら、そのcodeで閉じます。
クローズコードは、相手に残せる唯一の診断です。1006は送るコードではなく、「closeフレームなしで切れた」という意味で、受け取る側が付ける名前です。閉じる理由があるなら、必ず先にcloseフレームを送ってください。
本物のクライアントとつないでみる
追加で作るものはありません。採点ツールがwebsocketsライブラリのクライアントで自分のサーバーに接続し、ハングルのテキスト、70000バイトのバイナリ(8バイトの長さを使うサイズ)、pingを送って、1000で閉じます。ライブラリがプロトコル違反として接続を切らず、すべてのエコーとpong、クローズコード1000を受け取れば合格です。
手で作った実装は、手で作ったテストにしか通らないことがよくあります。ライブラリは、サーバーフレームのMASKビット、長さフィールドの最小エンコード、クローズの順序を、厳密に見ます。失敗すると、メッセージにライブラリが残した理由が出ます。