🔌

Rubyで楽しむソケットプログラミング入門

2021/12/04に公開

1. 本記事の背景

ネットワークのプログラムは、C言語のサンプルはたくさんありますが、Rubyのサンプルはあまり多くないと思います。特にRAWソケットのような比較的低レイヤを扱ったコードは少ないと思います。

Rubyのソケットプログラミングを試しているうちに、サンプルプログラムが手元に溜まりましたので、それらを整理がてらまとめておこうと思いました。

2. 動作確認環境

動作確認環境は以下の通りです。

  • Amazon Linux2(x86)
  • Ruby 3.1

3. ソケットについて

3.1. ソケットの基本

最初にソケットの概念について確認しておきます。本記事での「ソケット」とはTCP/IPで用いるネットワークのソケットのことを指します。

ソケットについての説明をいくつかの文献から抜粋します。

ソケットとは、アプリケーションがデータを送受信するための仕組みを抽象化したものです。(略)アプリケーションは、ソケットを使ってネットワークにつながることで、同じくネットワークにつながっているほかのアプリケーションと通信できます。(『TCP/IPソケットプログラミング C言語編』)

「ソケット」とは、ネットワークとネットワークの接続をソケット(コンセント)を繋ぐような感じで簡単にするため、アドレスとポートの組み合わせのセットにしたインターフェースのことを指します。『Linuxネットワークプログラミングバイブル

サーバとクライアントを結ぶ仮想的な接続を実現するのが「ソケット」であり、プログラミングでソケットを利用するときに使うのが「ソケットAPI」です。このソケットAPIはPOSIXという規定で決められており、多くのシステムで同じ記述が可能です。『Linuxネットワークプログラミング

3.2. ソケットの使い方(C言語)

システムコールレベルのソケットの使い方を知っておくと、Rubyのプログラミングにおいても理解がしやすいため、その使い方を確認しておきたいと思います。

ソケットの使い方について、簡単な説明を引用します。

ソケットにもさまざまな種類があり、どのようなソケットを作りたいのか、最初に指定しなければなりません。この指定は、socket()システムコ-ルの引数として渡します。(略)通信路で使われるプロトコルは、「アドレスファミリ」「ソケットタイプ」「プロトコル」の3つの組み合わせにより決定します。(『Linuxネットワークプログラミング』)

さらに、詳細な説明について、ソケットのmanのドキュメントから引用します。

int socket(int domain, int type, int protocol);

domain 引数は通信を行なうドメインを指定する; これはどの プロトコルファミリー (protocol family) を通信に使用するかを指定する。 これらのファミリーは <sys/socket.h> に定義されている。(略)

ソケットは type で指定される型を持ち、それは通信方式 (semantics) を指定する。 定義されている型は現在以下の通り。(略)

protocol はソケットによって使用される固有のプロトコルを指定する。

3.2.1. 例:UDPパケットの送信

3.2.1.1. ソケットの準備

UDPプログラミングの例をC言語で見ていきたいと思います。
上述のアドレスファミリ、ソケットタイプ、プロトコルの指定方法は以下の通りとなります。

項目
アドレスファミリ AF_INET
ソケットタイプ SOCK_DGRAM
プロトコル 17 or 0

3つ目のプロトコル番号はこちらで定義されていますので、利用するプロトコルの番号を指定します。

実際のOSの中では /etc/protocols を確認すれば分かります。また、番号ではなく定数での指定は /usr/include/netinet/in.h を確認すれば分かります。

今回はUDPなので17を指定すればOKです。
ただし、ソケットのmanのドキュメントにも記載があるように場合によっては0を指定することもできます。
今回は、AF_INET + SOCK_DGRAMでUDPが明確のため、0でも良いようです。

protocol はソケットによって使用される固有のプロトコルを指定する。通常それぞれの ソケットは、与えられたプロトコルファミリーの種類ごとに一つのプロトコルのみを サポートする。 その場合は protocol に 0 を指定できる。

3.2.1.2. コード例

それでは、実際に動作するソースコードを確認します。下記コードはこちらのサイトを参考にし、一部を修正しました。( DstIPAddress は宛先のIPアドレスに読み替えてください)

udp_c.c
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

int main()
{
 int sock;
 struct sockaddr_in addr;

 sock = socket(AF_INET, SOCK_DGRAM, 17); // プロトコル番号は 0 でもOK

 addr.sin_family = AF_INET;
 addr.sin_port = htons(12345);
 addr.sin_addr.s_addr = inet_addr("[DstIPAddress]");

 sendto(sock, "HELLO", 5, 0, (struct sockaddr *)&addr, sizeof(addr));

 close(sock);

 return 0;
}

動作結果をパケットキャプチャの形で添付します。UDPのデータ部に HELLO という文字列が格納されていることが分かります。

3.3. ソケットの色々

3.2. ではUDPを例にソケットの構成方法を具体的に確認しましたが、他のレイヤ・プロトコルを構成する場合は、以下のような組み合わせでソケットを構成します(No.は本記事用に便宜的に付与、主要なプロトコルを抜粋)。

例えば、TCP上のデータを扱う場合は、No.2のように AF_INET+SOCK_STREAM+IPPROTO_TCP(または0) を指定します。実際にソケットを宣言する際は以下のような形になります。

socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

また、UDPのヘッダそのものから編集したい場合は、 AF_INET+SOCK_RAW+IPPROTO_UDP を指定してソケットを作成します。

また、EthernetヘッダやIPヘッダ等のヘッダから編集したい場合は、アドレスタイプ AF_PACKET を指定します。その場合、こちらのドキュメントの通り、プロトコル番号はネットワークバイトオーダーで指定します。(表中では htons で記載)
例えば、Ethernetヘッダから編集したい場合は、 AF_PACKET+SOCK_RAW+htons(ETH_P_IP) を指定します。

4. Rubyによるソケットプログラミング

4.1. Rubyのソケットクラス

ソケットの基本について確認してきましたので、以下ではRubyによるソケットプログラミングをまとめていきます。

Rubyでは上述のTCP通信やUDP通信といったよく使われるソケットプログラミングについて、TCPSocketUDPSocketといった便利なクラスが提供されています。まずはそれらの使い方を確認してみたいと思います。

4.2. UDPソケットによる通信

まずUDPソケットを用いてみたいと思います。表中のNo.1に相当します。

Rubyの公式ドキュメントは以下です。

https://docs.ruby-lang.org/ja/latest/class/UDPSocket.html

4.1.1. 送信(クライアント)

例として、3.2.1. で確認した内容と同じパケットを送信するプログラムをRubyで書いてみたいと思います。

udp_c.rb
require "socket"

@udp = UDPSocket.open
@udp.send("HELLO", 0, "DstIPAddress", 12345)
@udp.close

ものすごく簡潔ですね・・!
プログラムを実行した際のパケットキャプチャも貼付けます。3.2.1. 同様、UDPのデータ部に HELLO という文字列が格納されていることが分かります。

4.1.1.1. #send の書き方

上記の #send メソッドでは引数を4つ取っていますが、2つ取る方法と、3つ取る方法もありますので、確認しておきたいと思います。

公式ドキュメントのまとめを引用します:

send(mesg, flags, host, port) -> Integer
send(mesg, flags, sockaddr_to) -> Integer
send(mesg, flags) -> Integer

引数が2つの場合は事前に #connect で宛先を指定しておく必要があります。

host, port の対、もしくは sockaddr_to で送り先を指定します。送り先を省略した場合は UDPSocket#connect で接続した先にデータを送ります。

引数が3つの場合の sockaddr_to は下記の通りです

sockaddr_to にはsocket/ソケットアドレス構造体を pack した文字列 もしくは Addrinfo オブジェクトを指定します。

それぞれの書き方のサンプルプログラムを以下にまとめます。
どの送信方法でも実行結果は同じです。

require "socket"

@udp = UDPSocket.open

@dst_addr = "18.180.200.17"
@dst_port = 12345
@msg = "HELLO"

# 引数が2つの場合
def send_with_2args
  @udp.connect(@dst_addr, @dst_port)
  @udp.send(@msg, 0)
end

# 引数が3つの場合
def send_with_3args
  sockaddr = Socket.pack_sockaddr_in(@dst_port, @dst_addr)
  @udp.send(@msg, 0, sockaddr)
end

# 引数が4つの場合
def send_with_4args
  @udp.send(@msg, 0, @dst_addr, @dst_port)
end

# send_with_2args
# send_with_3args
send_with_4args
@udp.close

4.1.2. 受信(サーバ)

最後に受信(サーバ)側のサンプルプログラムを確認しておきます。
#recvメソッドによりデータを受信します。

下記サーバのプログラムを実行した上で、4.1.1. のクライアントプログラムを実行すると サーバ上のコンソールで、 HELLO を出力して終了します。

srv.rb
require "socket"

udps = UDPSocket.open
udps.bind("[BindIPAddress]", 12345)

pp udps.recv(5) # => HELLO
udps.close

Ctrl+Cでプログラムを終了するまで待ち受けを続ける場合は、例えば以下のようにします。

require "socket"

udps = UDPSocket.open
udps.bind("[BindIPAddress]", 12345)

begin
  loop do
    pp udps.recv(5) # => HELLO
  end
rescue Interrupt
  puts "プログラムを終了します"
  udps.close
end

受信には #recv メソッドの他に recvfrom メソッドもあります。違いは、以下の通りです。

recv と同様にソケットからデータを受け取りますが、戻り値は文字列と相手ソケットのアドレス (形式は IPSocket#addr 参照) のペアです。引数については BasicSocket#recv と同様です。
https://docs.ruby-lang.org/ja/latest/method/IPSocket/i/recvfrom.html

上記サンプルプログラムの recvrecvfrom に変えた場合、クライアントからのデータ受信時のサーバ上のコンソールの出力結果は以下の通りになります。

["HELLO", ["AF_INET", [PortNumber], "[SrcIPAddress]", "[SrcIPAddress]"]]

4.3. TCPソケットによる通信

次に、TCPソケットによる通信の例を確認していきます。表中のNo.2に相当します。

Rubyの公式ドキュメントは以下です。

https://docs.ruby-lang.org/ja/latest/class/TCPSocket.html

また、実装は こちらのサイトを主に参考にさせて頂きました。

4.3.1. 送信(クライアント)

UDPの例と同様、 HELLO 文字列を送信するプログラムを実装してみます。
TCPSocket の公式ドキュメントは以下になります。

https://docs.ruby-lang.org/ja/latest/class/TCPSocket.html

UDPの時と同様、かなりシンプルに実装できます。

require "socket"

socket = TCPSocket.open("[DstIPAddress]", 12345) # この時点で3ウェイハンドシェイクによる接続を試みる
socket.send("HELLO", 0)
socket.close # FINを試みる

4.3.2. 受信(サーバ)

RubyではTCPのサーバを実現するための TCPServer というクラスが提供されていますので、これを使ってみます。公式ドキュメントは以下になります。

https://docs.ruby-lang.org/ja/latest/class/TCPServer.html

受信(サーバ)側のサンプルプログラムを実装します。

require "socket"

server = TCPServer.open(12345)
socket = server.accept
while buf = socket.gets
  pp buf
end

socket.close
server.close

4.3.3. 実行結果

実行結果のパケットキャプチャ結果を貼付けします。
3ウェイハンドシェイクの後に、データ HELLO を送信していると分かります。

4.4. RAWソケットによる通信

4.4.1. RAWソケットについて

次にRAWソケットの使い方を確認します。
上述の「TCPソケット」や「UDPソケット」はRubyで提供されているクラスを指していましたが、ここでいう「RAWソケット」はそのようなクラスがRubyで提供されているわけではありません。

生パケットをそのまま編集できるソケットタイプ SOCK_RAW を指定したソケットをRAWソケットと呼ぶようで、本記事でもそれを指します。

RAWソケットはTCPソケットやUDPソケットでは扱えないような低レイヤのヘッダを扱う時や、独自プロトコルのようなものを実装する際に使われると思います。

RAWソケットはSocketクラス経由で扱うことができます。

RAWソケットはアドレスタイプとして AF_INET を指定する場合(上記表のNo.3,4,5)と、 AF_PACKET を指定する場合(上記表のNo. 8)があります。

前者の場合は、IPヘッダより上のヘッダから編集可能です。例えば、UDPのヘッダやICMPのヘッダを編集できます。
後者の場合は、Ethernetヘッダから編集可能です。

以下では実装例として、ICMP Echo(いわゆるping)を、 AF_INET を用いた場合と、 AF_PACKET を用いた場合とで、それぞれ実装してみたいと思います。受信まで扱うと量が膨らむため、送信部分のみを扱います。

4.4.2. AF_INET+SOCK_RAW

まず、 AF_INET を指定したRAWソケットの実装例を見ていきます。表中のNo.5に該当します。

プログラムから編集できるのはICMPヘッダとICMPのデータ部となります。

4.4.2.1. ソケットの準備

前述の通り、RubyのSocketクラスを用いることで、RAWソケットを扱うことができます。例えば以下のように宣言することでRAWソケットを構成できます。

socket = Socket.open(
  Socket::AF_INET,      # IPv4
  Socket::SOCK_RAW,     # RAW Socket
  Socket::IPPROTO_ICMP  # ICMP
)

プロトコル部に Socket::IPPROTO_ICMP を指定しています。
これにより、IPヘッダ中のプロトコルフィールドにICMPが指定されます。

4.4.2.2. 送信データの準備

次に #send メソッドの引数となる送信データを準備します。送信データは大きくICMPヘッダと、ICMPのデータ部の2つを準備します。

後者は送信する文字列を準備するだけですので、前者を中心に説明します。

Rubyのソケットによるデータ送信(#send)の内容は文字列によって表現します。
最終的にバイナリ文字列(ASCIIコード)の形にすればいいようですので、その過程を説明します。

4.4.2.3. 例

ICMP Echo Requestを例に、実際にパケットを組み立てて、送信してみたいと思います。
ヘッダの構造はWikipwdia等を参照ください。

https://ja.wikipedia.org/wiki/Internet_Control_Message_Protocol

ICMP Echo Requestのヘッダ構造は以下の通りです。タイプとコードについては、値が決まっています。値が - の項目は固定値ではないという意味です。

項目 バイト数
タイプ 1 8
コード 1 0
チェックサム 2 -
識別子 2 -
シーケンス番号 2 -

ICMPヘッダの最初のフィールドは「タイプ」(1Byte)で、Echo Requestは 8 です。
数値の8はASCIIコードではBS(制御文字)です。Rubyのコンソールで表示すると \b のように表示されます。
バイナリ文字列への変換は#packメソッドを用います。(以前、バイナリ文字列周りの変換方法をこちらにまとめましたのでご参考まで)

type_bin = [8].pack("C") # => "\b"

次のフィールドは「コード」(1Byte)で、 値は 0 です。これもASCIIコードでは制御文字で、Ruby上は \x00と表示されます。

code_bin = [0].pack("C") # => "\x00"

次のフィールドは「チェックサム」(2Byte)です。実際にはヘッダの値にしたがって、チェックサムを都度計算が必要ですが、本記事では便宜的に 0x1234 としておきたいと思います(10進数で 4660)。

チェックサムは2Byteですので、 pack("S>") を使い、バイナリ文字列化します。ネットワークバイトオーダー(ビッグエンディアン)で変換するため S> を指定します。

checksum_bin = [0x1234].pack("S>") # => "\x124"

次のフィールドは「ID」(2Byte)です。本来はパケット毎に異なる値が格納される可能性がありますが、今回は仮で 0x0001 としておきます。
チェックサムと同じ要領でバイナリ文字列化します。

id_bin = [0x0001].pack("S>") # => "\x00\x01"

次のフィールドは「シーケンス番号」(2Byte)です。これも便宜上 0x0001 としておきます。同じ要領でバイナリ文字列化します。

seq_number_bin = [0x0001].pack("S>") # => "\x00\x01"

最後にヘッダとデータを結合すれば送信データが出来上がります。
できあがった文字列をデータ部と結合(データ部が後ろ)し、その文字列を #send メソッドに渡せばRAWソケットによる送信が可能となります。

icmp_header = type_bin + code_bin + checksum_bin + id_bin + seq_number_bin # => "\b\x00...."
data = "abcd"
icmp = icmp_header + data

socket.send(icmp, 0, Socket.sockaddr_in(nil, "[DstIPAddress]"))

本プログラムの全体は以下に置いていますので、全体の処理を確認したい場合は適宜参照ください。

https://github.com/kuredev/simple_ping

4.4.3. ICMP(AF_PACKET+SOCK_RAW)

次に、同じようにICMPパケットを送信する例を扱いますが、今度は AF_PACKET を使う例を紹介したいと思います。表中のNo.8にあたります。

このケースではEthernetヘッダからパケットを組み立てることができます。

イーサネットフレームの構造はWikipediaがわかりやすいです。
https://ja.wikipedia.org/wiki/イーサネットフレーム

ヘッダ構造は、以下の通りです。

項目 バイト数
宛先MACアドレス 6 -
送信元MACアドレス 6 -
イーサタイプ 2 Wikipedia

便宜的に宛先MACアドレスが 12:34:56:78:9a:bc 、送信元MACアドレスが 34:56:78:9a:bc:ed の場合を想定します。実際には宛先MACアドレスはARP等を用いて、宛先ホストまたはネクストホップルータのMACアドレスを取得する必要があります。
タイプはIPv4なので 0x0800 (2Byte)です。

ether_dhost = "12:34:56:78:9a:bc".split(":").map { |n| [n.to_i(16)].pack("C") }.join # => "\x124Vx\x9A\xBC" (Encoding:ASCII-8BIT)
ether_shost = "34:56:78:9a:bc:ed".split(":").map { |n| [n.to_i(16)].pack("C") }.join # => "4Vx\x9A\xBC\xED" (Encoding:ASCII-8BIT)
ether_type = [0x0800].pack("S>")

これらをイーサネットヘッダとして組み立てます。また、IPヘッダやICMPヘッダも同様に準備し、結合します。

# イーサネットヘッダ
ehternet_header = ether_dhost + ether_dhost + ether_type

# IPヘッダ
ip_header = ""# 省略

# ICMPヘッダ
icmp_header = ""# 省略

ether_frame = ehternet_header + ip_header + icmp_header + data
socket.send(ether_frame, 0, Socket.sockaddr_in(nil, "DstIPAddress"))

同じICMPパケットでもEthernetヘッダやIPヘッダから組み立てることができました。

5. おわりに

ソケットの使い方に関して、おおざっぱですがまとめてみました。

RubyにおけるTCPやUDPのソケットプログラミングは、ソケットクラスを用いることで、非常に簡便に実装が可能だと思いました。

他方、低レイヤのプログラムは構造体をヘッダファイルで直接参照可能なC言語の方が向いている面もあると思いましたが、Rubyの持つ言語としての手軽さや柔軟さは大きな魅力です。

以上です。

6. 主な参考資料

https://amzn.to/3DpkNF6

https://amzn.to/2WvQ0pq

https://amzn.to/3ojAwA4

https://amzn.to/3lwR461

https://amzn.to/3Ii2xA0

https://xtech.nikkei.com/it/article/COLUMN/20071031/285990/?P=3

https://www.geekpage.jp/programming/ruby-network/

Discussion