Ruby Advent Calendar 2021: パケットキャプチャ入門 with dRuby

訂正とお詫び (2022/3/5)

この記事の最後に「WiresharkdRubyに対応していないためキャプチャできなかった」と結論づけているのですが、筆者の不見識でWiresharkdRuby用のディセクタがビルトインされていることに気づいていませんでした。
申し訳ありません。

以下のように設定するとdRubyパケットをキャプチャすることが可能です。

Wireshark > Preferences > Protocols > DRb

ポート番号を指定する

f:id:shioimm:20220305154156p:plain

以下は元の記事 (未編集) です↓


Ruby Advent Calendar 2021 17日目の記事です。
昨日は@okuramasafumiさんでした。


この記事は

2台の機器 (MacBookThinkPad) がお互いに通信を行うような
dRubyスクリプトを書き、その実際の通信の様子をWiresharkで眺める

という小さな実験を行った際の記録です。

きっかけ

最近Wiresharkに入門したのですが、それと同時にdRubyにも興味が湧いて勉強していました。
ご存知dRubyは、ネットワーク越しにRubyオブジェクトを操作することができるフレームワークです。
HTTP通信と同じく、dRubyスクリプトを実行するときに機器間で実際に発生する通信は通常、TCP/IPプロトコルレイヤによって隠蔽されています
そこで機器間で送受信されたdRubyスクリプトが実際にパケットとして、あるいはアプリケーションデータ (※) としてネットワーク上をどんな風に流れているのか、その様子を見てみたいと思い立ち、今回の実験に至りました。

※ 実際にキャプチャした結果衝撃の事実に直面することになりました。むしろなぜこの時点で気づかなかった…

以下記事の内容に誤りなど技術的指摘がありましたら、お手数ですが教えてください。


動作環境

2台のPCのグローバルIPアドレスを調べたところ、

となっていました。

2台の機器は以下のように疎通確認済みです。

# ThinkPad
$ ping6 2400: ... :4808 -c 3
PING6(56=40+8+8 bytes) 2400: ... :439c --> 2400: ... :4808
16bytes from 2400: ... :439c, icmp_seq=0 ttl=64 time=4.26ms
16bytes from 2400: ... :439c, icmp_seq=1 ttl=64 time=4.86ms
16bytes from 2400: ... :439c, icmp_seq=2 ttl=64 time=4.25ms

--- 2400: ... :439c439c ping6 statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
# MacBook
$ ping6 2400: ... :439c -c 3
PING6(56=40+8+8 bytes) 2400: ... :4808--> 2400: ... :439c
16bytes from 2400: ... :4808, icmp_seq=0 hlim=64 time=94.183ms
16bytes from 2400: ... :4808, icmp_seq=1 hlim=64 time=116.538ms
16bytes from 2400: ... :4808, icmp_seq=2 hlim=64 time=34.263ms

--- 2400: ... :4808 ping6 statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss

MacからThinkPadへの通信にやたら時間がかかっていますが、弊環境だと大体いつもThinkPadの方が通信速度が優秀なのです…

改めてdRubyについて

皆さんご存知、Rubyのための分散オブジェクトシステムです。

分散オブジェクト技術 【distributed object technology】
library drb

dRubyによって、Rubyオブジェクトをプロセスやネットワークを超えて操作することが可能になります。

作者である咳さんご自身がdRubyの基本的な使い方について紹介されているRubyKaigi 2016の登壇動画はこちらです。

youtu.be

簡単な動作例を以下に示します。

ターミナルを二つ開いてそれぞれirbを起動し、drbライブラリをrequireします。

# ターミナル1 
$ irb --simple-prompt
>> require 'drb'
=> true
# ターミナル2
$ irb --simple-prompt
>> require 'drb'
=> true

続いてターミナル1でFooクラスとインスタンスメソッドfromを定義します。

# ターミナル1
?> class Foo
?>   def from
?>     "I'm from terminal 1!"
?>   end
>> end
=> :from

続いてターミナル1でDRb.start_serviceを実行すると、サーバープロセスを起動することができます。
このとき、DRb.start_serviceの第一引数にクライアントがアクセスするためのURI、第二引数にFooオブジェクトを渡します。
第二引数に渡したFooオブジェクトは他のプロセスから操作可能なオブジェクトとして公開されます。

# ターミナル1 
>> foo = Foo.new
>> puts foo
#<Foo:0x00007f81149b7cf0>
>> DRb.start_service("druby://localhost:8080", foo) # ローカルホストのポート番号8080で待ち受ける

これで、ターミナル2からターミナル1のFooオブジェクトに対してメソッド呼び出しができるようになります。
ターミナル2からFooオブジェクトに対してメソッド呼び出しするためには、まずターミナル2でDRbObject.new_with_uriを実行します。 このとき、引数にターミナル1のサーバープロセスが待ち受けているURIを指定します。

# ターミナル2
>> foo = DRbObject.new_with_uri('druby://localhost:8080')
>> puts foo
#<Foo:0x00007f81149b7cf0>

こうすることによってターミナル2からターミナル1のFooオブジェクトの複製であるオブジェクト (※) を取得することができます。

※ この辺り難しいので詳しくは後述のdRuby Bookを参照

ターミナル2から変数fooに格納されたオブジェクトに対してfromメソッドを実行すると…

# ターミナル2
>> foo.from
=> "I'm from terminal 1!"

ターミナル2にfromメソッドの返り値が表示されました!
ターミナル2から、ターミナル1で定義したFoo#fromのメソッド呼び出しに成功したということです。

逆に、ターミナル1からターミナル2にあるオブジェクトへの操作も試してみましょう。

# ターミナル2
?> class Bar
?>   def from
?>     "I'm from terminal 2!"
?>   end
>> end
=> :from
>> DRb.start_service("druby://localhost:8081", Bar.new) # ローカルホストのポート番号8081で待ち受ける
# ターミナル1
bar = DRbObject.new_with_uri('druby://localhost:8081')
bar.from
=> "I'm from terminal 2!"

こちらももちろん成功します。楽しいですね!

ちなみにBar#fromの内容を"I'm from terminal 2!"ではなくputs "I'm from terminal 2!"にしたりすると、ターミナル2に"I'm from terminal 2!"が表示され (ターミナル2の標準出力はターミナル2のままなので) 、ターミナル1にはnilが返ります (putsの返り値はnilなので) 。

他にもdRubyは様々な特徴を備えています。
詳しくはdRubyによる分散・Webプログラミング(あるいは英語版・無料のThe dRuby Book)、そして「令和のdRuby Book」といった趣きのn月刊ラムダノート Vol.2, No.1(2020)の特集「#2 dRubyで楽しむ分散オブジェクト」もおすすめです。

ThinkPadMacBook、それぞれの機器で実行するdRubyスクリプト

今回の実験では、次のようなプログラムを動作させることにしました。

  1. ThinkPadはサーバープロセスをポート番号8080で起動し、MacBookからアクセスすることができる空のKVS { } (…という名の空ハッシュ)を公開する
  2. MacBookはサーバープロセスをポート番号8081で起動し、ThinkPadからアクセスできるようにしておく
  3. MacBookThinkPadの公開しているKVS { } を取得する
  4. MacBookが取得したKVSにレコード{ "greeting" => "Hello from MacBook" }を追加する
  5. ThinkPadは自身が公開しているKVSにレコードが追加されたことを検知し、自身の標準出力にログを出力する
  6. MacBookが再びKVSにレコード{ "stdout" => $stdout }を追加する
  7. ThinkPadは再び自身が公開しているKVSにレコードが追加されたことを検知し、自身の標準出力にログを出力する
  8. ThinkPadはKVSに追加されたレコードがttyに結合している場合、結合先にメッセージ"Hello from ThinkPad"を出力する

f:id:shioimm:20211114215214p:plain

プログラムは次のようになります。

https://github.com/shioimm/til/tree/master/activities/learning_network_with_drbgithub.com

# thinkpad.rb

require 'drb'

kvs = {}

# 新たに追加されたレコードを検証するために使用する
last_kvs = {}

# サーバープロセスをポート番号8080で起動して空のKVSを公開
DRb.start_service("druby://#{ENV['LOCAL_HOST_ADDRESS']}:8080", kvs)
puts "Start server process on #{DRb.uri}"

loop do
  if kvs.size > last_kvs.size
    puts "New record has been added:\n#{kvs}"

    new_values = (kvs.values - last_kvs.values)
    new_stdouts = new_values.select { |value| value.respond_to?(:tty?) && value.tty? }

    unless new_stdouts.empty?
      # 追加されたレコードがttyに結合している場合は"Hello from ThinkPad"を出力
      puts "Greet to client: 'Hello from ThinkPad'"
      new_stdouts.each { |new_stdout| new_stdout.puts "Hello from ThinkPad" }
    end

    last_kvs = kvs.dup
  end

  sleep 1
end
# macbook.rb

require "drb"

# サーバープロセスをポート番号8081で起動
DRb.start_service("druby://#{ENV['LOCAL_HOST_ADDRESS']}:8081")
puts "Start server process on #{DRb.uri}"

# ThinkPadが公開しているKVSを取得
uri = "druby://#{ENV["REMOTE_HOST_ADDRESS"]}:8080"
kvs = DRbObject.new_with_uri(uri)

puts "[LOG] uri = #{uri}"
puts "[LOG] kvs = DRbObject.new_with_uri(uri)"
puts "[LOG] kvs:\n#{kvs}"

# KVSにレコード { kvs["greeting"] => "Hello from MacBook" } を追加
kvs["greeting"] = "Hello from MacBook"

puts "[LOG] kvs['greeting'] = 'Hello from MacBook'"
puts "[LOG] kvs:\n#{kvs}"

sleep 1

# KVSにレコード { kvs["stdout"] => $stdout } を追加
# この$stdoutはMacBook自身の標準出力
kvs["stdout"] = $stdout

puts "[LOG] kvs['stdout'] = $stdout"
puts "[LOG] kvs:\n#{kvs}"
puts "[LOG] sleep"; sleep

あらかじめ環境変数LOCAL_HOST_ADDRESSREMOTE_HOST_ADDRESSを各機器にセットしておきました。
ThinkPadLOCAL_HOST_ADDRESSThinkPadIPアドレス (末尾439c)、REMOTE_HOST_ADDRESSMacBookIPアドレス (末尾4808) です。
MacBookにはその逆になります。

これらのプログラムをそれぞれMacBookThinkPadに置き、それぞれ実行すると以下のようになります。

# ThinkPad
$ ruby thinkpad.rb
Start server process on druby://2400: ... :439c:8080
# MacBook
$ ruby macbook.rb
Start server process on druby://2400: ... :4808:8081
[LOG] uri = druby://2400: ... :439c:8080
[LOG] kvs = DRbObject.new_with_uri(uri)
[LOG] kvs:
{}
[LOG] kvs['greeting'] = 'Hello from MacBook'
[LOG] kvs:
{"greeting"=>"Hello from MacBook"}
# ThinkPad
...
New record has been added:
{"greeting"=>"Hello from MacBook"}
# MacBook
...
[LOG] kvs['stdout'] = $stdout
[LOG] kvs:
{"greeting"=>"Hello from MacBook", "stdout"=>#<DRb::DRbObject:0x00007fa55c8a57b0 @uri="druby://2400: ... :4808:8081", @ref=80>}
[LOG] sleep
# ThinkPad
...
New record has been added:
{"greeting"=>"Hello from MacBook", "stdout"=>#<DRb::DRbObject:0x00007fa55c8a57b0 @uri="druby://2400: ... :4808:8081", @ref=80>}
# MacBook
...
Hello from ThinkPad

無事にMacBookからThinkpadへ、ThinkPadからMacBookへ、お互い通信を行うことができました!

実際の通信の様子をWiresharkでキャプチャしてみる

上記のやりとりをキャプチャします。

WiresharkこちらからMacBookへダウンロードし、インストール済みです。
(ThinkPad(Ubuntu)にもaptを使ってインストールしたのですが、今回はクライアントであるMacBookでキャプチャしたファイルのみを確認します)

早速Wiresharkを開いてキャプチャを開始すると、次のような通信の様子が表示されました。

f:id:shioimm:20211124222737p:plain

この状態で、それぞれの機器でプログラムを実行します。

# ThinkPad

$ ruby thinkpad.rb
Start server process on druby://2400: ... :439c:8080
New record has been added:
{"greeting"=>"Hello from MacBook"}
New record has been added:
{"greeting"=>"Hello from MacBook", "stdout"=>#<DRb::DRbObject:0x00007fa55c8a57b0 @uri="druby://2400: ... :4808:8081", @ref=80>}
# MacBook
$ ruby macbook.rb
Start server process on druby://2400: ... :4808:8081
[LOG] uri = druby://2400: ... :439c:8080
[LOG] kvs = DRbObject.new_with_uri(uri)
[LOG] kvs:
{}
[LOG] kvs['greeting'] = 'Hello from MacBook'
[LOG] kvs:
{"greeting"=>"Hello from MacBook"}
[LOG] kvs['stdout'] = $stdout
[LOG] kvs:
{"greeting"=>"Hello from MacBook", "stdout"=>#<DRb::DRbObject:0x00007fa55c8a57b0 @uri="druby://2400: ... :4808:8081", @ref=80>}
[LOG] sleep
Hello from ThinkPad

プログラムの実行に成功したため、一旦ここでキャプチャを止めてファイルとして保存しました。

f:id:shioimm:20211124223713p:plain

保存したファイルを開き、今回実験した内容を通信に絞り込むため次のような条件でフィルタをかけました。

tcp.port == 8080 || tcp.port == 8081
  • 8080: ThinkPadのサーバープロセスのポート番号
  • 8081: MacBookのサーバープロセスのポート番号

すると…

f:id:shioimm:20211124225719p:plain

(上から下まで全部TCP)

(dRubyスクリプトが含まれているはずの) アプリケーションデータがキャプチャできていない…!?

…と、ここまで来て気づいたのですがWiresharkがキャプチャできるのはWireshark自身がサポートしているアプリケーションプロトコルのみなのでした。
残念ながらその中にdRubyは含まれていないため、キャプチャファイルではトランスポート層でやりとりしたTCPパケットの情報のみが表示されています。それはそう…!!

せっかくなので、dRubyならではのちょっと特徴的なパケットの様子を見てみたいと思います。

f:id:shioimm:20211124230138p:plain

先ほど実験用プログラムの中で、ThinkPadのサーバープロセスのポート番号を8080、MacBookのサーバープロセスのポート番号を8081としました。
通信の始まりであるこの行では、ThinkPadの8080番に対して、MacBookの55094番から[SYN]を送るところから通信が始まっています。
この時、MacBookはクライアントとしてパケットを送信しているため、自身のサーバーのプロセスの8081番ではなくてエフェメラルポートである55094番を使用しています。
2行目ではThinkPadの8080番からMacBookの55094番に対して[ACK]が返っていることが確認できます。

この後順調に通信が進んでいくのですが、190行目で突然シーケンス番号0の新しい通信が開始されます。

f:id:shioimm:20211124230324p:plain

見切れていますが送信元のIPアドレスの末尾が439c、宛先のIPアドレスの末尾が4808でポート番号が8081になっているので、これはThinkPadからMacBookのサーバーポート宛の通信であることがわかります。
(ThinkPadからMacBook"Hello from ThinkPad!"と送信している分です)
今度はThinkPadがクライアントになっているので、送信元のポート番号として38272という番号が割り当てられています。

最後に、それぞれの端末から通信をCtrl+Cで切断した部分がこちらです。

f:id:shioimm:20211124230610p:plain

シンプルなサーバー・クライアントシステムの場合、お互いが[FIN, ACK]と[ACK]を送り合っておしまいになると思うのですが、今回サーバープロセスとクライアントプロセスが2つずつあるため、通常の2倍の[FIN, ACK]と[ACK]が飛び交っていてちょっと面白いです。

まとめ

今回の実験の内容自体はdRuby的にもWireshark的にも最初の一歩を踏んだ程度のものではあるのですが、個人的にはパケットをキャプチャしてまじまじ眺める経験そのものが初めてだったためとても勉強になりました。
残念ながら送受信しているdRubyスクリプトそのものをアプリケーションデータとして確認することはできませんでした…が、実際にネットワークを超えて行くTCPパケットとしてのdRubyの姿を眺めることができて感動しました。
WiresharkdRubyもまだまだ奥が深そうなので、引き続き楽しんでいきたいと思います。


以上、「Ruby Advent Calendar 2021: パケットキャプチャ入門 with dRuby」でした。

明日は@yancyaさんです。
それでは皆さん楽しいクリスマスをお過ごしください🎄🍰