Rubyで楽しむソケットプログラミング入門
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アドレスに読み替えてください)
#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通信といったよく使われるソケットプログラミングについて、TCPSocketやUDPSocketといった便利なクラスが提供されています。まずはそれらの使い方を確認してみたいと思います。
4.2. UDPソケットによる通信
まずUDPソケットを用いてみたいと思います。表中のNo.1に相当します。
Rubyの公式ドキュメントは以下です。
4.1.1. 送信(クライアント)
例として、3.2.1. で確認した内容と同じパケットを送信するプログラムをRubyで書いてみたいと思います。
require "socket"
@udp = UDPSocket.open
@udp.send("HELLO", 0, "DstIPAddress", 12345)
@udp.close
ものすごく簡潔ですね・・!
プログラムを実行した際のパケットキャプチャも貼付けます。3.2.1. 同様、UDPのデータ部に HELLO
という文字列が格納されていることが分かります。
#send
の書き方
4.1.1.1. 上記の #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
を出力して終了します。
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
上記サンプルプログラムの recv
を recvfrom
に変えた場合、クライアントからのデータ受信時のサーバ上のコンソールの出力結果は以下の通りになります。
["HELLO", ["AF_INET", [PortNumber], "[SrcIPAddress]", "[SrcIPAddress]"]]
4.3. TCPソケットによる通信
次に、TCPソケットによる通信の例を確認していきます。表中のNo.2に相当します。
Rubyの公式ドキュメントは以下です。
また、実装は こちらのサイトを主に参考にさせて頂きました。
4.3.1. 送信(クライアント)
UDPの例と同様、 HELLO
文字列を送信するプログラムを実装してみます。
TCPSocket の公式ドキュメントは以下になります。
UDPの時と同様、かなりシンプルに実装できます。
require "socket"
socket = TCPSocket.open("[DstIPAddress]", 12345) # この時点で3ウェイハンドシェイクによる接続を試みる
socket.send("HELLO", 0)
socket.close # FINを試みる
4.3.2. 受信(サーバ)
RubyではTCPのサーバを実現するための TCPServer
というクラスが提供されていますので、これを使ってみます。公式ドキュメントは以下になります。
受信(サーバ)側のサンプルプログラムを実装します。
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等を参照ください。
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]"))
本プログラムの全体は以下に置いていますので、全体の処理を確認したい場合は適宜参照ください。
4.4.3. ICMP(AF_PACKET+SOCK_RAW)
次に、同じようにICMPパケットを送信する例を扱いますが、今度は AF_PACKET
を使う例を紹介したいと思います。表中のNo.8にあたります。
このケースではEthernetヘッダからパケットを組み立てることができます。
イーサネットフレームの構造はWikipediaがわかりやすいです。
ヘッダ構造は、以下の通りです。
項目 | バイト数 | 値 |
---|---|---|
宛先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. 主な参考資料
Discussion