225
196

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ShellScriptのみを使って今Webサーバを作るとしたら

Last updated at Posted at 2020-07-01

はじめに

本記事はQiita夏祭り2020の 「 〇〇(言語)のみを使って、今△△(アプリ)を作るとしたら」のテーマ記事となります.

皆さん,シェルスクリプトは書いてますか?
私は最近になってシェルスクリプトを書くようになったのですが,触って見たら意外と面白いなと思うようになりました.

そんなこんなで今回はシェルスクリプトでWebサーバ作ってみようかと思い立ちました.

ネタとしては何番煎じかわからないくらいありきたりですがご容赦ください🙇‍♂️

作ったもの

ポート4000でHTTPリクエストを受けてstatic配下のhtmlファイルを返すWebサーバをShellScriptで作成しました.

shell-server.gif

拙いコードですがGitHubにコードをおいておきます.

リポジトリへのリンク

実装方法

以下では最も初期の状態からどのように肉付けを行ったかを順に述べていきます.

開発環境はmacOSでシェルの動作はBashを想定しています.

1. 最も基本なHTTPサーバの立ち上げ

まず最も基本的な部分からです.
ncコマンドを使って4000番ポートでリクエストを受けます.
-wオプションはリクエストがタイムアウトするまでの時間です.

terminal(1)-server
$ nc -l 4000 -w 1

別のターミナルからcurlコマンドでlocalhost:4000にリクエストを投げます.

terminal(2)-client
$ curl http://localhost:4000

リクエストを投げるとterminal(1)側に以下のような出力がされ,リクエストを受けていることが確認できます.

terminal(1)-server
$ nc -l 4000 -w 1
GET / HTTP/1.1
Host: localhost:4000
User-Agent: curl/7.64.1
Accept: */*   

サーバ側の内容をserver.shという名前のシェルスクリプトに記述します.
以下ではこのファイルを拡張していくものとします.

terminal
$ touch server.sh
$ chmod +x server.sh
server.sh
#!/bin/bash

PORT=4000

# main
nc -l "$PORT" -w 1

2. 文字列をレスポンスとして返す

HTTPサーバということでHTTPリクエストに対し,HTTPレスポンスを返す必要があります.
1.でリクエストを受けることは確認できているのでHTTPレスポンスを返す実装をします.

HTTPレスポンスの構造は決まっています.

画像出典元: MDN Web Docs
HTTPの概要,HTTP メッセージ,レスポンス, https://developer.mozilla.org/ja/docs/Web/HTTP/Overview

このHTTPレスポンスの構造を反映した形の文字列を出力する関数responseを作成します.

server.sh
function response() {
  # HTTPプロトコルのバージョン,ステータスコード,ステータスメッセージ
  echo "HTTP/1.0 200 OK"
  # レスポンスヘッダー
  echo "Content-Type: text/plain"
  echo ""
  # レスポンスボディ
  echo "Hello, World"
}

ncコマンドでレスポンスを返すにはレスポンスをパイプを使って渡してあげます.

server.sh
# main
response | nc -l "$PORT" -w 1

3. 複数回リクエストが受け取れるようにする

ここまでだとサーバは一度リクエストを受けると停止してしまいます.
これを無限ループで回避します.

server.sh
# main
# ctrl+cで無限ループを抜けれるようにする
trap exit INT
# 無限ループでリクエストを受け取る
while true; do
  response | nc -l "$PORT" -w 1 
done

4. ログの出力

ログをファイルに出力するようにします.
ここでは,標準出力をstdout.log,標準エラー出力はstderr.logに出力するようにしてみます.
ただしファイル出力だけではなく,そのままコンソールにも表示するようにしてみます.

具体的にはncコマンドでサーバを立ち上げる前に以下のように記述します.

server.sh
# main
LOG_OUT="stdout.log"
LOG_ERR="stderr.log"

exec 1> >(tee -a $LOG_OUT)
exec 2> >(tee -a $LOG_ERR)

5. リクエストに応じてレスポンスを返す

ここまでの実装では全てのリクエストに対して,ステータスコード200,Hello, Worldの文字列を返す実装になっています.

今回はstaticディレクトリにindex.htmlを配置しhttp://localhost:4000/index.html以外のアクセスには404を返すような実装をしてみます.

terminal
$ mkdir static
$ touch static/index.html
index.html
<html>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>
terminal
$ tree .
├── static
│   └── index.html
└── server.sh

5.1 HTTPリクエストの構造からコンテンツのパスの取得

まずHTTPリクエストからパスを取得する必要があります.
HTTPリクエストの構造も決まっています.

画像出典元: MDN Web Docs
HTTPの概要,HTTP メッセージ,リクエスト, https://developer.mozilla.org/ja/docs/Web/HTTP/Overview

1行目にHTTPメソッドと取得したコンテンツのパス,HTTPのバージョンが記載されています.

今回は取得したいコンテンツのパスが取得できれば良いので,awkコマンドを利用して取り出します.
単純に考ええて,1行目を空白区切りにした際の2番目の文字列を取得します.
1行目の判断にはHTTPという文字列を含んでいるかで判断します.

terminal-awkコマンドによるパスの取得(確認用)
$ echo "GET /index.html HTTP/1.1" | awk '/HTTP/ { print $2 }'
/index

5.2 コンテンツの取得

取得した絶対パスに基づいてHTTPレスポンスを構成します.

この部分は別のシェルスクリプトを作成し処理を分離してしまいます.

terminal
$ touch get_content.sh
$ chmod +x get_content.sh
terminal
$ tree .
├── static
│   └── index.html
├── get_content.sh
└── server.sh

get_content.shでやることは引数にファイル名を受け,そのファイルがstatic配下に存在すれば200のHTTPリクエストを存在しなければ404のHTTPリクエストを構築します,

get_content.sh
#!/bin/bash

FILE_NAME=$1
STATIC_DIR=./static

if [ -f "$STATIC_DIR$FILE_NAME" ]; then
  echo "HTTP/1.0 200 OK"
  echo "Content-Type: text/html"
  echo ""
  cat "$STATIC_DIR$FILE_NAME"
else
  echo "HTTP/1.0 404 Not Found"
  echo "Content-Type: text/html"
fi

先ほどのawkコマンドの部分でget_content.shを呼び出すように変更します.
このときget_content.shにファイル名を引数として渡しています.

terminal
$ echo "GET /index.html HTTP/1.1" | awk '/HTTP/ {system("./get_content.sh " $2)}'

5.3 リクエストからレスポンスを構築

server.shの部分でリクエストに応じてレスポンスを返すように変更を加えます.

2.で述べたようにncコマンドではパイプで値を渡してあげることでレスポンスを返していました.

今回のようにリクエストに応じて,動的に処理を加えてパイプで値を渡すには少し工夫が必要になります.

ここで名前付きパイプによるプロセス間通信を使います,
名前付きパイプを使うにはmkfifoコマンドを利用します.

今回はstreamという名前付きパイプを作成し,ncコマンドではstreamから値を受けつつリクエストの情報をawkコマンドを使ってget_content.shに渡し,その結果をstreamに流すことでリクエストに応じてレスポンスを返すようにします.

server.sh
# 名前付きパイプstreamの作成
mkfifo stream
while true; do
  # streamで標準入力と標準出力をやりとりする
  nc -l "$PORT" -w 1 < stream | awk '/HTTP/ {system("./get_content.sh " $2)}' > stream
done

6. サーバを立ち上げた際にアスキーアートを表示

このままだと実行した時に寂しいので起動した際にfigletという文字列をアスキーアートで表示するコマンドを利用して見た目をお洒落にします.
関数initを定義し,起動時に呼び出すようにします.

server.sh
function init() {
  echo "-----------------------------------------------------"
  echo -n "[INFO] Start at: "
  date "+%Y/%m/%d-%H:%M:%S"
  echo "-----------------------------------------------------"
  figlet "bash server"
  echo "-----------------------------------------------------"
  echo "The Server is running at http://127.0.0.1:${PORT}"
  echo "-----------------------------------------------------"
}

# main
init

7. ブラウザからアクセスする

ここまでの実装でserver.shを起動してブラウザからアクセスしてみましょう.

terminal
$ ./server.sh
-----------------------------------------------------
[INFO] Start at: 2020/07/01-00:13:02
-----------------------------------------------------
 _               _                                    
| |__   __ _ ___| |__    ___  ___ _ ____   _____ _ __ 
| '_ \ / _` / __| '_ \  / __|/ _ \ '__\ \ / / _ \ '__|
| |_) | (_| \__ \ | | | \__ \  __/ |   \ V /  __/ |   
|_.__/ \__,_|___/_| |_| |___/\___|_|    \_/ \___|_|   
                                                      
-----------------------------------------------------
The Server is running at http://127.0.0.1:4000
-----------------------------------------------------

ブラウザでhttp:localhost:4000/index.htmlにアクセスするとHello, Worldの文字を確認することができます.
なお,それ以外のパスでアクセスすると404になります.

image.png

最終的なserver.shのコードは以下のようになっています.

server.sh
#!/bin/bash

PORT=4000

function init() {
  echo "-----------------------------------------------------"
  echo -n "[INFO] Start at: "
  date "+%Y/%m/%d-%H:%M:%S"
  echo "-----------------------------------------------------"
  figlet "bash server"
  echo "-----------------------------------------------------"
  echo "The Server is running at http://127.0.0.1:${PORT}"
  echo "-----------------------------------------------------"
}

function response() {
  echo "HTTP/1.0 200 OK"
  echo "Content-Type: text/plain"
  echo ""
  echo "Hello, World"
}


##################################################
# main部分
##################################################

init

# ログの設定
LOG_OUT="stdout.log"
LOG_ERR="stderr.log"

exec 1> >(tee -a $LOG_OUT)
exec 2> >(tee -a $LOG_ERR)

# 名前付きパイプがあった場合は先に消しておく
if [ -e "./stream" ]; then
  rm stream
fi

trap exit INT
mkfifo stream
while true; do
  nc -l "$PORT" -w 1 < stream | awk '/HTTP/ {system("./get_content.sh " $2)}' > stream
done

8. ディレクトリトラバーサルの脆弱性対策(追記)

(2020/07/03 10:15 追記)

@MILADOLL_decchi さんよりget_conten.shでのディレクトリトラバーサルの脆弱性についてコメントをいただきました.
その部分について追記したいと思います.

※ コメントに記載しましたが実行時にはncコマンドの時点である程度防げているようですが保険的に対策をしておきます.

ディレクトリトラバーサルの脆弱性による攻撃方法はコメントにある通りです.
対策としてはファイル名を受け取るパラメータに検証をすることがあげられます.

以下は少し古いですがIPAの対策指針です.

ファイル名をパラメータとして受け取るプログラムは、次の入力検査を行う。

(1) Unix, GNU/Linuxの場合

a. 可能なら、ファイル名パラメータの仕様として、ディレクトリ修飾のあるファイル名(「/」を含むパス名)を禁止するものとし、その仕様に沿った入力検査を行う
b. 「/」ではじまるパス名(絶対パス名)を受理しない
c. 「../」(親ディレクトリ修飾)を含むパス名を受理しない

出典元: IPA 独立行政法人 情報処理推進機構,
プログラムからのファイル流出対策, https://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/402.html

a. については今回のWebサーバだとstatc配下に更にディレクトリを作成する場合を考慮して「/」を含むパス名は禁止しません.

b. については参照先のディレクトリをget_content.shstatic配下に固定しているので既にできているものとします.

よってc. についてget_content.shで対策をしておきます.

具体的にはファイル名に../を含んでいた場合はHTTPのステータスコード400,Bad Requestを出力するようにしたいと思います.

ファイル名を検証する関数validateを作成します.

get_content.sh
function validate() {
  if [[ "$FILE_NAME" =~ ^.*\.\.\/.*$ ]]; then
    echo "HTTP/1.0 400 Bad Request"
    echo "Content-Type: text/html"
    exit 0
  fi
}

# main
validate

これで最初に引数のパラメータ検証が行われるようになります.
試しにget_content.sh ../../../etc/passwdを渡して実行してみます.

terminal
$ ./get_content.sh ../../../etc/passwd
HTTP/1.0 400 Bad Request
Content-Type: text/html

これできちんとステータスコード400のレスポンス内容が返されることがわかります.

おわりに

実用性は無いですがShellScriptでWebサーバを作ってみました.

Webサーバの動作を学ぶのにはやってみるのは良いのでは無いでしょうか.
シェルについての理解がまだまだ浅いので詰めていきたいと思います.

より良い書き方や内容に間違いなどがありましたらご指摘お願いいたします.

参考文献

225
196
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
225
196

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?