はじめに
本記事では、gemなどを一切使わず、Rubyの組み込みライブラリのみを使用して、Webサーバーを自作します。
この記事の内容は下記の「Webサーバーを作りながら学ぶ 基礎からのWebアプリケーション開発入門」を参考にしています。
この書籍自体は、Javaで書かれていますが、どの言語にも通じる根本の部分が分かりやすく書かれているのでとてもおすすめです。
TCPサーバーの実装
TCP/IPを用いた通信について
Webサーバーをブラウザはインターネットを経由して通信を行います。
この通信にはTCP/IPというプロトコルが使用されています。
TCP/IPプロトコルではIPアドレス、ポート番号を使用して通信相手を特定します。
例えば、Webサーバーのネットワーク上の住所(IPアドレス)である、「127.0.0.1」と
Webサーバープログラムのコンピュータ上の住所(ポート)である、「80」を組み合わせて
「127.0.0.1:80」という形で通信相手を指定します。
上記のようにIPアドレス、ポートを指定してあげれば情報を送る側は情報を受け取る側を特定できます。
しかし、通信を行うにはこれだけでは不十分です。
送られた情報を受け取る側もTCP/IP用にポートを待機させておく必要があります。
これをプログラムから行うには、コンピューターにポート80はTCP/IP通信用に使うよ!と
教えてあげる必要があります。それを実装するためにソケットと呼ばれるものを使用します
ソケットとは
ソケットとは、TCPやUDPを利用して通信するためによく使用されるAPIのことです。
通信の接続口と言われたりします。
クライアント側のソケットとサーバー側のソケットを接続し、
接続されたソケットに書き込み、読み込みを行うことで互いの情報を交換することができます。
なお、本記事でソケットという言葉を使う場合は、ソケットの中でも特にTCP通信を担うTCPソケットのことを指します。
Rubyでソケットを使ってTCPサーバー、TCPクライアントを実装する
Rubyでソケット通信を実装するにはSocketライブラリのTCPSocketクラスとTCPServerクラスを使用します。
TCPSocketはクライアント記述用のクラスで、
TCPServerはサーバー記述用のクラスです。
サーバー側の実装
まずはサーバー側の実装です。下記のサンプルプログラムをご覧ください。
クライアントからの書き込みを1バイトずつ読み込んで、server_recv.txt
というファイルに書き込み、
server_send.txt
というファイルを読み込んでsocketに書き込むという処理をしています。
require "socket"
begin
# ポート3030をオープンし、クライアントからの接続を受け付ける
server = TCPServer.open(3030)
puts("クライアントの接続を受け付けています")
socket = server.accept
puts("クライアント接続")
# server_recv.txtにsocketから読み出した文字列を1つずつ書き出す
File.open("server_recv.txt", "w") do |file|
# 0が送られてくるまで、socketへの書き込みを1バイトずつ読み出し続ける
while (ch = socket.read(1)) != "0"
file.write(ch)
end
end
File.foreach("server_send.txt") do |line|
socket.write(line)
end
rescue => e
puts e
ensure
socket.close
server.close
end
TCPServer.open(3030)
で、指定したポートのサーバー接続をオープンし、
server.accept
でクライアントからの接続を受け付けています。
TCPServer#accept()
はクライアントとの接続が行われるとTCPSocketインスタンスを返します。
File.open(~)
の部分では、socketから読み出した文字列をserver_recv.txt
というファイルに書き出しています。
socket.read(1)
で、クライアントがソケットに書き込んだ内容を1バイトずつ読み出しています。
(引数で一度に読み出すバイト数を設定できます。)
while (ch = socket.read(1)) != "0"
の部分で、
クライアントから"0"という文字列が書き込まれるまで読み出しを行います。
"0"である必要はありませんが、クライアントの書き込み終了の合図はなにかしら事前に決めておく必要があります。
今度は、File.foreach(~)
でserver_send.txt
というファイルを読み出し、サーバーからソケットに書き込みを行います。
最後にsocket.close
で接続を切断し, server.close
でポート3030を閉じます。
クライアント側の実装
require "socket"
socket = TCPSocket.new("localhost", 3030)
# client_send.txtの内容を一行ずつ書き込む
File.foreach("client_send.txt") do |file_line|
socket.write(file_line)
end
# 書き込み終了の合図として0を書き込む
socket.write("0")
File.open("client_recv.txt", "w") do |file|
while ch = socket.read(1)
file.write(ch)
end
end
TCPSocket.new("localhost", 3030)
の部分で、localhostのポート3030に接続します。
接続に成功するとTCPSocketインスタンスを返します。
File.foreach(~)
でclient_send.txt
を一行ずつ読み出し、
socket.write
で書き込んでいます。
書き込みが終わったら、クライアント側の書き込み終了の合図として"0"を書き込みます。
最後に、File.open(~)
でサーバーから書き込まれた内容を読み出し、server_send.txt
というファイルを読み出し、サーバーからソケットに書き込みを行います。
サーバー側でsocketの接続を切断しているのでクライアント側では特にclose処理をしていません。
その他
クライアントからサーバー、サーバーからクライアントに書き込む準備をするため、
上記で読み出していた、"server_send.txt", "client_send.txt"を作成します。
中身は両者が区別できればなんでも良いです。
server string
server string
server string
server string
server string
server string
server string
server string
server string
server string
client string
client string
client string
client string
client string
client string
client string
client string
client string
client string
接続してみる
では、早速作成したプログラムを実行し見ましょう。
まずはTCPサーバーを起動します。
ruby tcp_server.rb
# => "クライアントの接続を受け付けています"
別ターミナルで起動したTCPサーバーにTCPクライアントから接続を行います
ruby tcp_client.rb
接続に成功するとTCPサーバー側のターミナルにクライアント接続
という文字列が出力され、
現在のディレクトリ下にserver_recv.txt
, client_recv.txt
が作成されます。
中身を確認すると、下記のようになっています。
client string
client string
client string
client string
client string
client string
client string
client string
client string
client string
server string
server string
server string
server string
server string
server string
server string
server string
server string
server string
それぞれ、server_recv.txt
はclient_send.txt
と 、
client_recv.txt
はserver_send.txt
と中身が同じであれば通信成功です。
これでRubyのソケットライブラリを使用してTCPサーバーを実装し、TCPクライアントとの接続を行うことができました。
Webサーバーの実装
Webサーバーについて
ここまでTCPサーバーを作成して、TCPクライアントとの接続を行いました。
Webサーバーを実装するためにはこのTCPサーバーをHTTPプロトコルに適用させる必要があります。
つまり、クライアントからHTTPリクエストを受け取って、HTTPレスポンスを返すようにする必要があります。
そのためには、HTTPリクエストとHTTPレスポンスについて知る必要があります。
これらについて詳しく見ていきましょう。
HTTPリクエスト
HTTPにおいてクライアントがサーバー側に送るものです。
先程のtcp_server.rbを少し改良して実際のHTTPリクエストを確認してみます。
require "socket"
begin
server = TCPServer.open("localhost", 3030)
puts "クライアントからの接続を待ちます。"
socket = server.accept()
puts "クライアント接続"
# server_recv.txtにsocketから読み出した文字を一つずつ書き出す
File.open("server_recv.txt", "w") do |file|
# 0が送られてくるまで、socketへの書き込みを1バイトずつ読み出し続ける
while (line = socket.gets) != "\r\n" #<= ここだけ変更
file.write(line)
end
end
File.foreach("server_send.txt") do |file_line|
socket.write(file_line)
end
rescue => e
puts e
ensure
socket.close
server.close
end
先程はread(1)
で一文字ずつ読み出していましたが、socket.gets
で1行ずつ取り出します、
また、クライアントの書き込み終了を"\r\n"
という文字列で判断するようにしています。
こちらを実行し、ご自身が使用しているブラウザからhttp://localhost:3030 にアクセスしてみてください。
すると、server_recv.txt
に下記のような内容が書き込まれるはずです。
GET /index.html HTTP/1.1
Host: localhost:3030
Connection: keep-alive
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="107", "Chromium";v="107", "Not=A?Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
これがHTTPリクエストの正体です。
最も重要なのは一行めのGET /index.html HTTP/1.1
です。
この部分をHTTPリクエストラインといい、ここではGETメソッドを使用して指定したリソース(/index.html)の参照をリクエストしています。
Webサーバー側はこのリクエストに応え、/index.html
にあるリソースをHTTPレスポンスボディに含めて、返却する必要があります。
2行目以降はHTTPリクエストヘッダーが並んで、最後に空行(\r\n)が入ります。
つまり、HTTPリクエストとは下記のような形式をしています。
HTTPリクエストライン
HTTPリクエストヘッダー1
HTTPリクエストヘッダー2
HTTPリクエストヘッダー3
HTTPリクエストヘッダー4
...
\r\n
HTTPレスポンス
今度はHTTPレスポンスを見てみましょう。
dockerでnginxを起動してリクエストを送り、そのレスポンスをみてみます。
下記を実行しnginxをポート8081で起動します。
docker run -p 8081:80 nginx:latest
ちなみに、webサーバーならnginxではなく、Apacheなどでも大丈夫ですし、
dockerを使いたくない方は公式インストールページからインストールしていただいても大丈夫です。
レスポンスの中身を見たいだけなので、用意するのが面倒という方は
このまま記事を追ってくださればそれでも問題ありません。
ブラウザからアクセスして、nginxが起動したことを確認しましょう。
TCPサーバー実装時に使用したtcp_client.rbに少し手を加えて、client_send.txtに先程のHTTPヘッダーをコピペします。
require "socket"
socket = TCPSocket.new("localhost", 8081) # <= ここを変更
# client_send.txtの内容を一行ずつ書き込む
File.foreach("client_send.txt") do |file_line|
socket.write(file_line)
end
socket.write("\n") # <= ここを追加
socket.write("0")
File.open("client_recv.txt", "w") do |file|
while ch = socket.read(1)
file.write(ch)
end
end
GET /index.html HTTP/1.1
Host: localhost:3030
Connection: keep-alive
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="107", "Chromium";v="107", "Not=A?Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
tcp_client.rbを実行すると、client_recv.txtに下記の内容が書き込まれます。
HTTP/1.1 200 OK
Server: nginx/1.23.2
Date: Sun, 04 Dec 2022 09:57:44 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Wed, 19 Oct 2022 07:56:21 GMT
Connection: keep-alive
ETag: "634fada5-267"
Accept-Ranges: bytes
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
内容を見てみると一行ずつHTTPレスポンスヘッダーが並び、
一行の空白で隔ててHTTPレスポンスボディが記載されるという構成になっています。
つまり、HTTPレスポンスは下記のような形式をしています。
HTTPレスポンスヘッダー1
HTTPレスポンスヘッダー2
HTTPレスポンスヘッダー3
HTTPレスポンスヘッダー4
...
\r\n
HTTPレスポンスボディ
HTTPレスポンスを返却する
それでは実際にHTTPレスポンスを返却する部分を実装します。
main部分の実装
require "socket"
require "./server_thread"
server = TCPServer.open(3030)
begin
socket = server.accept
# "\r\n"だけの行になるまで、1行ずつsocketへの書き込みを読み込む
while (line = socket.gets) != "\r\n"
path = line.split(" ")[1] if line&.start_with?("GET")
end
# Dateヘッダー用に現在時刻をUTCで取得し、形式を整える
utc_date_string = DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")
# HTTPレスポンスヘッダーの書き込み
socket.write("HTTP/1.1 OK\n")
socket.write("Date: #{utc_date_string}\n")
socket.write("Server: RubySimpleServer\n")
socket.write("Connection: close\n")
socket.write("Content-type: text/html\n")
# 空白行
socket.write("\r\n")
# HTTPレスポンスボディの書き込み
File.foreach("src/#{path}") do |line|
socket.write(line)
end
rescue => e
puts e
ensure
socket.close
server.close
end
server、socketをオープンし、読み取りと書き込みを行う大枠の流れに変化はありませんが、
個別の処理を見ていきましょう。
# "\r\n"だけの行になるまで、1行ずつsocketへの書き込みを読み込む
while (line = socket.gets) != "\r\n"
path = line.split(" ")[1] if line&.start_with?("GET")
end
先程確認した通り、HTTPリクエストの最終行には空行("\r\n")が入るので、
空行が入るまで一行ずつ読み出します。
また、リクエストラインで指定されたpathにあるリソースを返却したいので、G
ETから始まっている列がある場合はpath部分を取り出し変数に格納するようにしています。
# Dateヘッダー用に現在時刻をUTCで取得し、形式を整える
utc_date_string = DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")
# HTTPレスポンスヘッダーの書き込み
socket.write("HTTP/1.1 OK\n")
socket.write("Date: #{utc_date_string}\n")
socket.write("Server: RubySimpleServer\n")
socket.write("Connection: close\n")
socket.write("Content-type: text/html\n")
確認した、HTTPレスポンスの形式通り、最初にHTTPレスポンスヘッダーの書き込みをしています。
ここでは最低限のヘッダーしか含めていません。
# 空白行
socket.write("\r\n")
# HTTPレスポンスボディの書き込み
File.foreach("src/#{path}") do |line|
socket.write(line)
end
HTTPヘッダーの書き込みが終わったら、空白行を1行挟み、HTTPレスポンスボディを書き込みます。
書き込む内容は、pathで指定されたファイルの内容です。
参照する静的ファイルの実装
次に、srcディレクトリを作成し、index.htmlを配置します。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Main Page</title>
</head>
<body>
<h1>メインページです。</h1>
<p>メインページのパラグラフです。</p>
</body>
</html>
main.rbを実行し、ブラウザからhttp://localhost3030/index.html にアクセスすると下記のようにindex.htmlが表示されます。
試しに別のhtmlも追加してみます。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Test Page</title>
</head>
<body>
<h1>テストページです</h1>
<p>テストページのパラグラフです。</p>
</body>
</html>
再度main.rbを実行し、ブラウザから今度はhttp://localhost:3030/test.html にアクセスします。
すると、test.htmlが表示されます。
これで無事HTTPリクエストに応じた静的ファイルをHTTPレスポンスとして返却することができました!
Webサーバーの実装完了です!..といきたいところですがこのままだとまだ不十分です。
複数リクエストに対応する
現状のままだとリクエストが1つ送られてきた途端にサーバープログラムは終了します。
これでは実用的ではありません。
そこで、RubyのThreadを使用して、複数リクエストに対応できるようにします。
ここではThread詳細の説明はしませんが、ざっくり言えば、Rubyで並行処理を実現するためのクラスです。
実装は下記のようになります。
require "socket"
require "./server_thread"
server = TCPServer.open(3030)
begin
loop do
puts "クライアントからの接続を待ちます"
Thread.start(server.accept) do |socket|
puts "クライアント接続"
server_thread = ServerThread.new(socket)
server_thread.run
end
end
rescue => e
puts e
ensure
server.close
end
main部分で変更があったのは下記です。
loop do
puts "クライアントからの接続を待ちます"
Thread.start(server.accept) do |socket|
puts "クライアント接続"
server_thread = ServerThread.new(socket)
server_thread.run
end
end
ここやっていることは、server.accept
でクライアントからの接続を待ち受けて、
接続が行われたら、Thread処理を開始します。
mainプログラム自身は再度ループして、server.accept
を実行し、クライアントからの接続を待ち受けます。
Thread処理内では、ServerThreadインスタンスを生成しServerThread#run
を実行しています。
ServerThreadはThread処理の中身を別クラスとして切り出したもので、中身は下記のようになっています。
require "socket"
class ServerThread
def initialize(socket)
@socket = socket
end
def run
begin
while (line = @socket.gets) != "\r\n"
path = line.split(" ")[1] if line&.start_with?("GET")
end
utc_date_string = DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")
@socket.write("HTTP/1.1 OK\n")
@socket.write("Date: #{utc_date_string}\n")
@socket.write("Server: RubySimpleServer\n")
@socket.write("Connection: close\n")
@socket.write("Content-type: text/html\n")
@socket.write("\r\n")
File.foreach("src/#{path}") do |line|
@socket.write(line)
end
rescue => e
puts e
ensure
@socket.close
end
end
end
初期化時にsocketをインスタンス変数として受け取っている以外は前回実装したHTTPレスポンスを返却する処理と変化ありません。
これで、何度リクエストしてもサーバープログラムは終了せず、リクエストに応じたレスポンスが返却されるようになりました!
ちなみに、サーバープログラムを終了したければCtrl + C
で終了できます。
404 Not Foundページを返却する
現状のWebサーバーでは、リソースが存在すれば無事表示されますが、存在しない場合はエラーが発生します。
そこで、指定されたpathにリソースが存在しない場合は404 Not Foundページを返却するようにします。
require "socket"
require "./http_response"
class ServerThread
BASE_DIR = "src"
def initialize(socket)
@socket = socket
end
def run
begin
while (line = @socket.gets) != "\r\n"
path = line.split(" ")[1] if line&.start_with?("GET")
end
path_to_file = File.join(BASE_DIR, path)
if File.exist?(path_to_file)
HttpResponse.send_ok(@socket, path_to_file)
else
HttpResponse.send_not_found(@socket)
end
rescue => e
puts e
ensure
@socket.close
end
end
end
File.exist?で指定されたpathにファイルが存在するかチェックしています。
ファイルが存在すれば、そのファイルを存在しなければ404ページを返却するようにしています。
200レスポンスと404レスポンスの書き出しをserver_thread.rbに書き出すと、
runメソッドが肥大化するので、HttpResponseという別のmoduleを作成し、
ServerThreadからはHttpResponse.send_ok
またはHttpResponse.send_not_found
を呼び出すようにします。
http_response.rb自体は下記のようになっています。
require "./util"
module HttpResponse
PATH_TO_NOT_FOUND = "src/404.html"
SERVER_NAME = "RubySimpleServer"
class << self
def send_ok(socket, path)
socket.write("HTTP/1.1 200 OK\n")
socket.write("Date: #{Util.get_utc_date_string}\n")
socket.write("Server: #{SERVER_NAME}\n")
socket.write("Connection: close\n")
socket.write("Content-type: text/html\n")
socket.write("\r\n")
write_body(socket, path)
end
def send_not_found(socket)
socket.write("HTTP/1.1 404 Not Found\n")
socket.write("Date: #{Util.get_utc_date_string}\n")
socket.write("Server: #{SERVER_NAME}\n")
socket.write("Connection: close\n")
socket.write("Content-type: text/html\n")
socket.write("\r\n")
write_body(socket, PATH_TO_NOT_FOUND)
end
private
def write_body(socket, path)
File.foreach(path) do |line|
socket.write(line)
end
end
end
end
send_okの処理はいままでのレスポンス生成処理とほとんど変わりはありません。
変更はserver名を定数で切り出している点、
日付の取得処理をUtilというこれまた別moduleに切り出している点、
ファイルを読み出してsocketに書き出す処理を別methodに切り出している点の3点です
send_not_foundでは、"HTTP/1.1 200 OK"ではなく"HTTP/1.1 404 Not Found"を返却します。
また、pathは常に一定なので引数で受け取らず、PATH_TO_NOT_FOUND
という定数で、
常にsrc/404.html
を参照するようにしています。
Utilモジュールは時刻用文字列を生成するmethodを一つ持つのみです。
require "date"
module Util
class << self
def get_utc_date_string
DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")
end
end
end
あとは、404.htmlを作成し、src/直下に配置します。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Not Found</title>
</head>
<body>
<h1>404 Not Found</h1>
<p>ページが見つかりませんでした。</p>
</body>
</html>
これで、準備は全て整いました!
一度、サーバーを終了してからもう一度起動して、http://localhost:3030/hoge.html にアクセスしてみましょう。
src/hoge.htmlは存在しないので、404ページが表示されました!
これで、存在しないリソースへのpathが指定されても安心です。
html以外にも返却できるようにする
これまでは、実装したWebサーバーを使ってhtmlファイルのみを返却していましたが、
Webサーバーでは画像(png, jpg)やjsonなど別ファイルも返却することもあります。
この項目ではその対応をしようと思います。
拾ってきたフリー画像をsrc/img/test.jpgに配置し、ブラウザからhttp://localhost:3030/img/test.jpg にアクセスしてみます。
すると下記のように、意味不明な文字列が表示されます。
これは、今までHTTPレスポンスヘッダーのContent-Typeにtext/htmlを指定していたためです。
ファイルの内容を正確に返却するためには、返却するファイルの拡張子に応じて、
適切なContent-Typeを指定してあげる必要があります。
Content-Typeの一覧とその説明は下記の記事がわかりやすく簡潔にまとまっています。
リンク先で並べられているContent-Type全てではありませんが、それでもこれを全て実装するのは大変なので今回は、
下記のみを対象にします。
- text.html
- text/plain
- text/css
- text/csv
- application/json
- image/png
- image/jpeg
- image/gif
また、これらのどれにも当てはまらない場合はapplication/octet-stream
を返すようにします。
util.rbを下記のように修正します。
require "date"
module Util
CONTENT_TYPES = {
html: "text/html",
htm: "text.html",
txt: "text/plain",
css: "text/css",
csv: "text/csv",
json: "application/json",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif"
}
class << self
def get_utc_date_string
DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")
end
def get_ext(path)
File.extname(path).gsub(".", "")
end
def get_content_type(ext)
CONTENT_TYPES[ext] || "application/octet-stream"
end
end
end
CONTNT_TYPEという定数で、拡張子とContent-Typeの対応表をhashで定義します。
#get_content_type()
で、引数で受け取った拡張子を使用して、CONTENT_TYPESから対応するContent-Typeを取得し、見つからなければapplication/octet-stream
を返却します。
#get_ext()
は渡されたpathから拡張子の.以降の部分を取得します。
あとはこれらをHttpResponse.send_ok()内で呼び出すだけです。
def send_ok(socket, path)
ext = Util.get_ext(path)&.to_sym
socket.write("HTTP/1.1 200 OK\n")
socket.write("Date: #{Util.get_utc_date_string}\n")
socket.write("Server: #{SERVER_NAME}\n")
socket.write("Connection: close\n")
socket.write("Content-type: #{Util.get_content_type(ext)}\n")
socket.write("\r\n")
write_body(socket, path)
end
これで、渡されたpathにあるファイルの拡張子によってContent-Typeが変わるようになりました。
サーバーを再起動し、先程のhttp://localhost:3030/img/test.jpg にアクセスしてみましょう。
画像が表示されました!
ディレクトリトラバーサル対策をする
画像も返却できていい感じにWebサーバーが出来上がってきました。
ただ、このWebサーバーには致命的な脆弱性があります。
それはpathをなんのフィルターもなしに直接指定している点です。
今まではsrc/直下のファイルにのみアクセスしていたからよかったのですが、悪意のある攻撃者がpathを../main.rb
のように指定した時、main.rbの内容が盗み見られたり、あるいは実行したくないファイルを実行させられてしまったりします。
この脆弱性をディレクトリトラバーサルの脆弱性と言います。
そうならないためには、指定されたパスが正しいディレクトリの直下にあるかどうかを確認し、不正なpathであれば404を返すようにします。
パスが正しいか検証するmethodをUtilに追加します。
require "date"
module Util
~
BASE_DIR = "src"
ABSOLUTE_BASE_DIR = File.expand_path(BASE_DIR, __dir__)
class << self
def is_valid_path?(path)
absolute_file_path = File.expand_path(File.join(ABSOLUTE_BASE_DIR), path)
return absolute_file_path.start_with?(ABSOLUTE_BASE_DIR)
end
~
end
end
BASE_DIR
は検証に使用するのでServerThread
から移動させました。
is_valid_path?()が検証用のmethodです。
検証にはFile.expand_pathを使用して絶対パスに展開しています。
ABSOLUTE_BASE_DIR
はsrcをホームディレクトリからの絶対パスで展開したパスです。
ABSOLUTE_BASE_DIR
とpathをJOINして、再度絶対パスに展開したものが、
ABSOLUTE_BASE_DIR
で始まっていれば、srcより下の階層にある有効なpathなので、trueを返却します。
反対にABSOLUTE_BASE_DIRで始まっていなければ、srcより上の階層を指定する無効なpathなのでfalseを返します。
あとはこれをServerThread側で呼び出すだけです。
~
path_to_file = File.join(Util::BASE_DIR, path)
# 指定されたpathが、無効ならばnot_foundを返す
return HttpResponse.send_not_found(@socket) if !Util.is_valid_path?(path_to_file)
if File.exist?(path_to_file)
~
ServerThread#run
の中でUtil.is_valid_path?
を呼び出しfalseが返ってきたら404Not Foundページを返却します。
これでpathに不正な値が指定されても大丈夫になりました。
ディレクトリが指定された時にリダイレクトする
現状のWebサーバーではリクエスト時にかならずfile名まで明記する必要があります。
例えば、http://localhost:3030/ にアクセスするとNotFoundとなってしまいます。
これを、ディレクトリを指定したらindex.htmlにリダイレクトされるようにしたいです。
リダイレクトを実現するためには、HttpResponse
に新たに301 Moved Permanently
を担う
send_moved_permanently
を追加実装します。
def send_moved_permanently(socket, location)
ext = Util.get_ext(path)&.to_sym
socket.write("HTTP/1.1 301 Moved Permanently\n")
socket.write("Date: #{Util.get_utc_date_string}\n")
socket.write("Server: #{SERVER_NAME}\n")
socket.write("Connection: close\n")
socket.write("Content-type: #{Util.get_content_type(ext)}\n")
socket.write("Location: #{location}\n")
socket.write("\r\n")
end
send_okとほとんど一緒ですが、ステータスコードが301であることの他に、
Locationヘッダーを使用して、リダイレクト先を指定しています。
次はserver_thread.rb
の修正です。
if File.directory?(path_to_file)
# ディレクトリを指定している場合は、index.htmlを返す
path_to_file = File.join(path_to_file, "index.html")
location = "http://localhost:3030#{path_to_file}"
HttpResponse.send_moved_permanently(@socket, location)
elsif File.exist?(path_to_file)
HttpResponse.send_ok(@socket, path_to_file)
else
HttpResponse.send_not_found(@socket)
end
File.directory?を使用して、指定されたpathがディレクトリであるかどうかを判断しています。
結果、pathがディレクトリであれば末尾にindex.html
をJOINしたものから新たなurlを生成し、
send_moved_permanentlyにlocationとして渡しています。
これでサーバーを再起動して、http://localhost:3030にアクセスしてみましょう。
http://localhost:3030/index.html にリダイレクトされれば成功です!
注意
HTTPステータスコードの301はMoved Permanently、つまりリソースが永久にリダイレクト先へ移動されたことを意味します。
ブラウザはこれを理解し、一度301が返ってきたパスに再度アクセスするときは、ブラウザのキャッシュを参照するようになっています。
そのため、どれだけ301のエンドポイントに修正を加えても、2回目以降のアクセスはキャッシュに残っている前のレスポンスが参照されることになり、
それが残っている限りは、変更が反映されません。
解決策としては、ブラウザのキャッシュを削除してから再度リクエストを行うことです。
chromeなら、右上の「︙」 => 「その他のツール」 => 「閲覧履歴の消去」からキャッシュの削除が行えますので、適宜ご利用ください。
(だたし、他サイトのキャッシュも削除されるのでご注意ください。)
URLデコードを実装する
長々と書いていた本記事もラストの項目です。
最後はURLデコードを実装します。これは、例えば日本語のディレクトリ名を使用している場合などに必要です。
実際の例を見てみましょう。
src/日本語/index.html
という場所にindex.htmlを配置してみます。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>日本語 ページ</title>
</head>
<body>
<h1>日本語ページです。</h1>
<p>日本語ページのパラグラフです。</p>
</body>
</html>
この状態で、http://localhost:3030/日本語/index.html にアクセスするとどうなるでしょうか。
答えは404 Not Foundが表示されます。なぜでしょうか。
理由は単純で、ブラウザはWebサーバーにHTTPリクエストを送る時、URLをエンコードしてから送っているためです。
putsで先程のpathを出力すると下記のようになります。
src/%E6%97%A5%E6%9C%AC%E8%AA%9E/index.html
このようにエンコードされた文字列が送られてくるので、該当するファイルが見つからず、404ページが表示されていたのです。
したがって元の文字列を取り出したい場合は、これをデコードしてあげる必要があります。
そのためには、CGIライブラリのunescapeメソッドを使用します。
def run
begin
~
decoded_path = CGI.unescape(path)
decoded_path_to_file = File.join(Util::BASE_DIR, decoded_path)
# 指定されたpathが、無効ならばnot_foundを返す
return HttpResponse.send_not_found(@socket) if !Util.is_valid_path?(decoded_path)
if File.directory?(decoded_path_to_file)
# ディレクトリを指定している場合は、index.htmlを返す
decoded_path_to_file = File.join(decoded_path, "index.html")
location = "http://localhost:3030#{decoded_path_to_file}"
HttpResponse.send_moved_permanently(@socket, location)
elsif File.exist?(decoded_path_to_file)
HttpResponse.send_ok(@socket, decoded_path_to_file)
else
HttpResponse.send_not_found(@socket)
end
rescue => e
puts e
ensure
@socket.close
end
end
Util::BASE_DIR
とJOINする前に、CGI.unescape()
でデコードして元の文字列を取り出しています。
こうすることで、日本語のディレクトリ名も正しく受け取ることができます。
再度、http://localhost:3030/日本語/index.html にアクセスしてみましょう。
無事表示されました!
おわりに
本記事で実装したものは下記のリポジトリに挙げています。
実用には程遠いかもしれませんが、最低限動作するWebサーバーが組み込みライブラリのみで実装できたと思います。
普段外部の便利なライブラリを使用していると気が回らなくなりがちな部分ですが、自分で作ってみると中身がわかって楽しかったりします。
本記事ではWebサーバーを作成しましたが、今度はRubyの組み込みライブラリのみでWebアプリケーションサーバーを作ってみたいと思っています。
この長い記事を最後までご覧いただきありがとうございました!