今年も登録してしまったアドベントカレンダー。。
リブセンス @eri です。転職サイトジョブセンスリンクの開発をしています。一昨日の晩ごはんは築地でふぐ刺しを食べました。
チャットアプリからのチャットを他端末から送れたりしないかなー、からの流れでチャットの仕組みが気になり、Websocketについて調べていました。
HTTP通信と異なるWebsocketの特徴として、1コネクションでの双方向通信、転送データが軽量、非同期などなどありますが、今回はデータ転送パケットの構造をメインに説明します。
Websocket概要
Websocketの全容は、分かりやすいスライドがあったのでそちらを見ていただくとして。
http://www.slideshare.net/mawarimichi/websocketwebrtc
Websocket通信が確立するまでに、サーバー・クライアント間で色々なやり取り(Websocket opening ハンドシェイクと呼ばれる)が行われます。
通信(実はTCP)が確立した後、低コストでのデータ転送が行われます。ヘッダー長が長くなりがちなHTTP通信よりもかなり転送量が抑えられています。
しかし、転送内容にはクライアントが送りたい素のデータ入っているのではなく、Websocketのプロトコルで組み立てられたデータが転送されます。勉強がてら、パケットからクライアントデータを取り出すスクリプトを書いていきます。
準備
まず、Websocketでテキストをやり取りするだけの仕組みを作っておきます。
OS/クライアント環境/利用言語・モジュール
- Mac OS 10.11.1
- Google Chrome 47
- Node.js v4.2.2
- websocket (Node.jsモジュール)
- ruby 2.0.0p645
クライアント側
<!DOCTYPE html>
<html>
<head>
<title>websocket sample</title>
</head>
<body>
<input type="text" id="message"> <input type="button" id="send" value="submit">
<script>
(function() {
var ws = new WebSocket("ws://localhost:8008");
var output = document.getElementById('output');
var send = document.getElementById('send');
send.addEventListener('click', function() {
var msg = document.getElementById('message').value;
ws.send(msg);
});
}());
</script>
</body>
</html>
サーバー側
var http = require('http');
var clientHtml = require('fs').readFileSync('index.html');
var httpServer = http.createServer(function(req, res) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(clientHtml);
});
httpServer.listen(8008);
var WebSocketServer = require('websocket').server;
var wsServer = new WebSocketServer({ httpServer: httpServer });
wsServer.on('request', function (req) {
var connection = req.accept(null, req.origin);
connection.on('message', function(msg) {
console.log(msg.utf8Data);
});
});
サーバーを立ち上げ、
$ node app.js
http://localhost:8008 にアクセス
ここクライアント側でテキストをsubmitすると、サーバー側でechoされます。
#パケット取得
パケットを取得するために、テキストsubmit直後の通信をdumpします。
$ tcpdump dst port 8008 -i lo0 -s0 -w send.pcap
サーバー側でListenしてるポート番号で絞り込み、
-i lo0
でlocalhost間の通信のみを絞り込んでます。
今回は7バイトのASCIIテキストを送信。
send.pcapに保存されました。
#TCPデータ取得
dump結果はそのままでは読めない(バイナリ)、各通信プロトコルを解読していくのは面倒、とにかくTCPデータ本体だけ見たい、ということでWiresharkの力を借ります。
WiresharkはGUIだけでなくCLIも用意されてあり、インストール後tshark
コマンドが使えるようになります。
データだけ欲しい、というオプションを指定してあげると、
$ tshark -r send.pcap -T fields -e data
8187e1a4c61d8cc5f46e80cfa7
16進数でTCPのデータが取れました。13バイトしかありません。
#バイトごとの取り出し
取れたデータを操作するために、16進文字列のn+1バイト目を、符号なし整数で取り出すメソッドもここで作っておきます。(Ruby)
tcp_data = `tshark -r send.pcap -T fields -e data`.chomp
def tcp_data.get_char(n)
[self].pack("H*")[n].unpack("C*")[0]
end
16進文字列をバイナリ変換して1バイト取り出し、それをchar型に変換しています。
#Websocketデータ解読
Websocketでのデータはフレームと呼ばれており、RFC6455 の Base Framing Protocolにフレームの読み方が記載されています。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
横幅4バイト分の表になっています。
フレームプロトコルの各要素を見ていきます。端折っているので、詳しくはRFC6455を見てください。
FIN
送られたフレームが最後のフラグメントだった場合に1。
(Websocketではデータをフラグメントという単位に分割して送ることも出来ます。)
(tcp_data.get_char(0) & 0b10000000) >> 7
=> 1
今回1。
RSV1, RSV2, RSV3
0以外の値にて意味を定義する拡張がネゴシエーションされていない限り、0でなければならない。通常0。
(tcp_data.get_char(0) & 0b01110000) >> 4
=> 0
全て0になっています。
opcode
Payload dataの形式を指定。先程フォームから送信したデータにあたる。テキストの場合1。バイナリデータもWebsocketで送信可能で、その場合2となる。その他、接続closeの際の値や、今後の拡張のための予約済みの値など様々決められている
tcp_data.get_char(0) & 0b00001111
=> 1
テキストを送信したので1。
Mask
1の場合、Payload dataがMasking-keyでマスクされている。
クライアントからサーバーへの送信の場合は1。
(tcp_data.get_char(1) & 0b10000000) >> 7
=> 1
クライアントから送信してるため1。
Payload len
Payload dataの長さ(バイト数)
tcp_data.get_char(1) & 0b01111111
=> 7
7バイトのテキストであることがわかる。
Extended payload length
Payload lenが126か127だった場合に指定されるので、今回はなし。
Masking-key
Maskが1の場合、Masking-keyが存在する。Payload Dataをマスキングしている。
TCPデータの3バイト目〜6バイト目。
Payload Data
Websocketのデータ部。先程フォームから送信したテキストにあたる。
TCPデータの7バイト目〜13バイト目。
#クライアントデータのアンマスク
Maskが適用されているのでアンマスクしてあげます。
Payload Dataに対し、Masking-keyを1バイトずつXORすると、アンマスクすることができます。
masking_key_start_index = 2
payload_start_index = 6
payload_length = tcp_data.get_char(1) & 0b01111111
data = ''
payload_length.times do |i|
# Masking-keyは4バイトしかないため、それよりもPayload Dataが長い場合は繰り返し適用
masking_key_index = masking_key_start_index + (i % 4)
masking_key_char = tcp_data.get_char(masking_key_index)
masked_char = tcp_data.get_char(payload_start_index + i)
# Masking-keyとPayload Dataを1バイトずつXOR
data << (masking_key_char ^ masked_char)
end
#パケット解析スクリプトまとめ
パケットを渡すとクライアントデータが返るスクリプトとして、以上をまとめます。
tcp_data = `tshark -r send.pcap -T fields -e data`.chomp
def tcp_data.get_char(n)
[self].pack("H*")[n].unpack("C*")[0]
end
masking_key_start_index = 2
payload_start_index = 6
payload_length = tcp_data.get_char(1) & 0b01111111
data = ''
payload_length.times do |i|
masking_key_index = masking_key_start_index + (i % 4)
masking_key_char = tcp_data.get_char(masking_key_index)
masked_char = tcp_data.get_char(payload_start_index + i)
data << (masking_key_char ^ masked_char)
end
puts data
実行すると、
$ ruby parse_packet.rb
ma2saka
できました!
エラー処理無し、テキストデータのみ、125バイト以下のデータのみ、別途パケットキャプチャが必要、など課題はありますが一例を出せたのでよしとします。
#後日談
Websocketといえば、socket.ioが有名なモジュールのうちの一つです。
当初、Websocketを動かすためにsocket.io使ってたのですが、データフレームを解析してみると、アンマスクしてもテキストが謎の記号の羅列だったり、RSV1が1だったり、結局解読出来ず今回のwebsocketモジュールを使いました。socket.ioでは「意味を定義する拡張がネゴシエーション」されてるのでしょうか。
またsocket.ioでの通信パケットを眺めていると、同じテキストを複数回送信すると、TCPデータ長が短い値に収まっていく傾向にありました。更にデータ量を削減するために、内部で工夫してるのかもしれません。
明日はオフィスで席が右隣の新卒同期 @ktmg に続きます!!