はじめに
本記事はQiita夏祭り2020の 「 〇〇(言語)のみを使って、今△△(アプリ)を作るとしたら」のテーマ記事となります.
皆さん,シェルスクリプトは書いてますか?
私は最近になってシェルスクリプトを書くようになったのですが,触って見たら意外と面白いなと思うようになりました.
そんなこんなで今回はシェルスクリプトでWebサーバ作ってみようかと思い立ちました.
ネタとしては何番煎じかわからないくらいありきたりですがご容赦ください🙇♂️
作ったもの
ポート4000でHTTPリクエストを受けてstatic
配下のhtmlファイルを返すWebサーバをShellScriptで作成しました.
拙いコードですがGitHubにコードをおいておきます.
実装方法
以下では最も初期の状態からどのように肉付けを行ったかを順に述べていきます.
開発環境はmacOSでシェルの動作はBashを想定しています.
1. 最も基本なHTTPサーバの立ち上げ
まず最も基本的な部分からです.
nc
コマンドを使って4000番ポートでリクエストを受けます.
-w
オプションはリクエストがタイムアウトするまでの時間です.
$ nc -l 4000 -w 1
別のターミナルからcurl
コマンドでlocalhost:4000
にリクエストを投げます.
$ curl http://localhost:4000
リクエストを投げるとterminal(1)
側に以下のような出力がされ,リクエストを受けていることが確認できます.
$ nc -l 4000 -w 1
GET / HTTP/1.1
Host: localhost:4000
User-Agent: curl/7.64.1
Accept: */*
サーバ側の内容をserver.sh
という名前のシェルスクリプトに記述します.
以下ではこのファイルを拡張していくものとします.
$ touch server.sh
$ chmod +x 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
を作成します.
function response() {
# HTTPプロトコルのバージョン,ステータスコード,ステータスメッセージ
echo "HTTP/1.0 200 OK"
# レスポンスヘッダー
echo "Content-Type: text/plain"
echo ""
# レスポンスボディ
echo "Hello, World"
}
nc
コマンドでレスポンスを返すにはレスポンスをパイプを使って渡してあげます.
# main
response | nc -l "$PORT" -w 1
3. 複数回リクエストが受け取れるようにする
ここまでだとサーバは一度リクエストを受けると停止してしまいます.
これを無限ループで回避します.
# main
# ctrl+cで無限ループを抜けれるようにする
trap exit INT
# 無限ループでリクエストを受け取る
while true; do
response | nc -l "$PORT" -w 1
done
4. ログの出力
ログをファイルに出力するようにします.
ここでは,標準出力をstdout.log
,標準エラー出力はstderr.log
に出力するようにしてみます.
ただしファイル出力だけではなく,そのままコンソールにも表示するようにしてみます.
具体的にはnc
コマンドでサーバを立ち上げる前に以下のように記述します.
# 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を返すような実装をしてみます.
$ mkdir static
$ touch static/index.html
<html>
<body>
<h1>Hello, World</h1>
</body>
</html>
$ 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
という文字列を含んでいるかで判断します.
$ echo "GET /index.html HTTP/1.1" | awk '/HTTP/ { print $2 }'
/index
5.2 コンテンツの取得
取得した絶対パスに基づいてHTTPレスポンスを構成します.
この部分は別のシェルスクリプトを作成し処理を分離してしまいます.
$ touch get_content.sh
$ chmod +x get_content.sh
$ tree .
├── static
│ └── index.html
├── get_content.sh
└── server.sh
get_content.sh
でやることは引数にファイル名を受け,そのファイルがstatic
配下に存在すれば200のHTTPリクエストを存在しなければ404のHTTPリクエストを構築します,
#!/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
にファイル名を引数として渡しています.
$ 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
に流すことでリクエストに応じてレスポンスを返すようにします.
# 名前付きパイプ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
を定義し,起動時に呼び出すようにします.
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
を起動してブラウザからアクセスしてみましょう.
$ ./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
になります.
最終的な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
を作成します.
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
を渡して実行してみます.
$ ./get_content.sh ../../../etc/passwd
HTTP/1.0 400 Bad Request
Content-Type: text/html
これできちんとステータスコード400のレスポンス内容が返されることがわかります.
おわりに
実用性は無いですがShellScriptでWebサーバを作ってみました.
Webサーバの動作を学ぶのにはやってみるのは良いのでは無いでしょうか.
シェルについての理解がまだまだ浅いので詰めていきたいと思います.
より良い書き方や内容に間違いなどがありましたらご指摘お願いいたします.