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

# はじめに

本記事はQiita夏祭り2020の [「 〇〇（言語）のみを使って、今△△（アプリ）を作るとしたら」](https://qiita.com/official-events/5d181aadf5db26b73d33)のテーマ記事となります．

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

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

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

# 作ったもの

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

![shell-server.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/225901/8401eba8-0ddd-7651-1b4c-892d4eb82fc8.gif)

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

[リポジトリへのリンク](https://github.com/wf-yamaday/bash-server)

<a href="https://github.com/wf-yamaday/bash-server"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/wf-yamaday/bash-server.png" width="460px"></a>



# 実装方法

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

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


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

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

```bash:terminal(1)-server
$ nc -l 4000 -w 1
```

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

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

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

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

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

```bash:terminal
$ touch server.sh
$ chmod +x server.sh
```

```bash:server.sh
#!/bin/bash

PORT=4000

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



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

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

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

![](https://mdn.mozillademos.org/files/13691/HTTP_Response.png)

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

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

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

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

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



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

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

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

## 4. ログの出力

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

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

```bash: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を返すような実装をしてみます．

```bash:terminal
$ mkdir static
$ touch static/index.html
```

```html:index.html
<html>
  <body>
    <h1>Hello, World</h1>
  </body>
</html>
```

```bash:terminal
$ tree .
├── static
│   └── index.html
└── server.sh
```


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

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

![](https://mdn.mozillademos.org/files/13687/HTTP_Request.png)

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

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

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

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

### 5.2 コンテンツの取得

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

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

```bash:terminal
$ touch get_content.sh
$ chmod +x get_content.sh
```

```bash:terminal
$ tree .
├── static
│   └── index.html
├── get_content.sh
└── server.sh
```

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

```bash: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`にファイル名を引数として渡しています．

```bash: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`に流すことでリクエストに応じてレスポンスを返すようにします．

```bash: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`を定義し，起動時に呼び出すようにします．

```bash: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`を起動してブラウザからアクセスしてみましょう．

```bash: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/225901/35e540cf-68e3-969a-9e51-261a183432ca.png)

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

```bash: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.sh`で`static`配下に固定しているので既にできているものとします．

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

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

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

```bash: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:terminal
$ ./get_content.sh ../../../etc/passwd
HTTP/1.0 400 Bad Request
Content-Type: text/html
```

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

# おわりに

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

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

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



# 参考文献

- [ncコマンドで簡易HTTPサーバ](http://blog.livedoor.jp/sonots/archives/34703829.html)
- [ShellScriptで作るウェブサーバー](https://qiita.com/trkbt10/items/0faf7a3e48d22c650abc)
- [bashでストリームデータ処理](https://qiita.com/debug-ito/items/1bc877d62ec04dfa938a)