シェルスクリプト で Amazon S3 ライクな RESTful WEB サーバーを作ってみた

タイトルが少しおかしいですが、シェルスクリプトが好きすぎて、シェルスクリプトで RESTful なつもりの CMS 兼 WEB サーバーを作成してみました。ここでは、このシェルスクリプト製 WEB サーバー兼 CMS について紹介させていただきます。API 操作のデザインについて Amazon S3 に多分に影響を受けています。

## 作ったモノと特徴
[GitHub - o8vm/xsk: CMS and Web Server written in ShellScript](https://github.com/o8vm/xsk)

基本的には Markdown の供給をメインとする CMS 兼 WEB サーバーとなっています。

Markdown ファイルを配置しておくと HTML に変換して表示してくれます。
また、PATHとクエリに応じてシェルスクリプトを実行することもできる CGI の側面も持ちます。
コンテンツを操作するために公開している API と認証機構について Amazon S3 から大きな影響を受けていて、HTTP メソッドを使用した操作に加えて、 AWS バージョン4署名プロセスに似た方法による認証に対応します。

次の 4 つの基本的な設計原則に従うように作成したつもりです。

1. 明示的に `GET`/`PUT`/`POST`/`PATCH`/`DELETE`/`OPTIONS`/`HEAD`などのメソッドを使う。
2. ステートレスにする
3. ディレクトリ構造ににた URI を公開する
4. HTTP/HTTPS に対応する

以下、上記と他の特徴について簡単に説明します。

### 明示的に HTTP メソッドを使う

REST では明示的に HTTP メソッドを使う必要があるとされていますが、XSK でもできるだけ、HTTP プロトコルをその定義どおりの方法で使うように心がけました
XSK では、各種メソッドを下記リストに示すような方法で使います。

* `GET`: リソースの表現を取得します
* `PUT`: 指定された URI に新規リソースを作成するか、既存 URI の上書き更新(常に新規)
* `POST`: 既存の URI へ従属するリソースの新規作成、追記。MIMEマルチパートデータを利用
* `PATCH`: 既存リソースに対して diff データを元にパッチを当てて更新します
* `DELETE`: 既存リソースの削除を行います
* `OPTIONS`: 許可されているメソッド一覧を返します
* `HEAD`: ここでは、単にリソースの存在有無を確かめ、ステータスコードでその結果を返します。

上記の API のいくつかは Amazon S3 の API に影響を受けています。

### ステートレス
一応ステートレスです。状態を保持せず、クライアントは、パラメーターやコンテキスト、データを、リクエストの HTTP ヘッダーとボディーの中に含めることとしています。

### ディレクトリ構造に似た URI を公開する
URI の構造は単純かつ予測可能で、容易に理解できるものであるべきとされています。この観点から、XSK では UNIX のディレクトリ構造をそのまま URI の階層構造に適用します。指定した WEB サーバーのルート用ディレクトリからヴァーチャルパスを構成し、1 つのパス上にルートがあり、そのパスから、サービスの各領域を公開するサブパスが分岐しています。なお、これはディレクトリでありファイルです。とても直感的だと思います。
UI もそれに準じた直感的なものであるべきだろうと思ったので、インデックスページは、単純に `tree` コマンドの実行結果のような index ページを返すようにしました。実装には find コマンドを使用しています。

ファイル / ディレクトリを公開するか否かは完全に UNIX のファイルのパーミションの機能によって決定されます。

### HTTP/HTTPS 対応
HTTPとHTTPS の両方に対応しています。証明書を設置すれば、HTTPS でコンテンツを公開できます。
例えば、Google Domainsで dev ドメインを取得した場合には必須の機能でしょう。

### AWS 署名バージョン 4 署名プロセス
XSK はもともと `GET`, `PUT`, `POST`, `PATCH`, `DELETE`, `OPTIONS`, `HEAD` などの HTTP メソッドに対応していますが、`GET` 以外はAWS 署名バージョン 4 署名プロセスにたプロセスで署名した `Authorization` ヘッダを持たない限りアクセスできません。
XSK では、この署名の計算と検証もシェルスクリプトで行います。

### markdown 対応
拡張子が `.md` で終わるファイルを `GET` する場合には、サーバーサイドのシェルスクリプトでHTML に変換してから供給します。ちなみに、生成される HTML は twitter で共有された時にサムネを表示する twitter card に対応しています。HTML のテンプレートをセットするだけなので特徴でもなんでもないですが。 twitter card に対応しています。

### シェルスクリプトを実行可能
公開ディレクトリ内部に配置されているファイルが`.sh`拡張子であり、かつ実行可能な場合には、そのスクリプトファイルをGETすると、そのスクリプトが実行された結果が返されます。例えばシェルスクリプトにはパラメータとして、`?`以降のクエリを渡すことができます。もちろんどのように処理するかは実行スクリプト側の責任で自由です。

### 互換性
実は微塵もテストはしていないのですが、互換性に注意して書いたつもりです。
内部で使用しているコマンドは POSIX の [Shell & Utilities: Table of Contents](https://pubs.opengroup.org/onlinepubs/009695399/utilities/contents.html) に定められているコマンド以外は `socat` と `openssl`, `curl` / `wget` のみです。
なお、便利スクリプトとして、[秘密結社シェルショッカー 日本支部 · GitHub](https://github.com/ShellShoccar-jpn)の方々が作成されたいくつかのコマンドに加えて、シェルスクリプトで作成された独自のスクリプトも使用しています。ありがとう！シェルショッカー。

さて、以上の特徴を持つ XSK を使用して、実際に WEB サービスを作成してあるので、実際に動作する例とともにどのように動くのかもう少し説明したいと思います。

## 動作例と使い方
XSK で作成した実際の web サービスは以下になります。ただの個人ブログですが、気になる方はのぞいてみてください。

[VMM.DEV](https://vmm.dev/)

上記のサイトは、完全にシェルスクリプトとpure JavaScriptのみで作成されています。
なお、XSK のディレクトリ構成は下記のようになっています。

### XSK (EXOSKELETON) の構成と起動
```shell
$ tree -a .
.
├── .log # <============== アクセスログ格納ディレクトリ
├── .xsk # <============== 認証情報とかのディレクトリ
│   ├── credentials # <=== 認証情報(アクセスキー, シークレットアクセスキー)
│   ├── fullchain.pem # <= 証明書と中間証明書を連結したファイル(HTTPS用)
│   └── privkey.pem # <=== 秘密鍵(HTTPS用)
├── exo # <=============== 公開用ルートディレクトリ
│   ├── .attachments # <== css/js/cgi用シェルスクリプト置き場
│   │   ├── css
│   │   ├── img
│   │   ├── js
│   │   └── sh
│   └── ... # <=========== (ここにmarkdownファイルを配置していく)
|
├── pilot # <============= クライアントコマンド
├── skeleton 
|   └── ... # <=========== 共通のコアスクリプト/ツール
├── srvcs # <============= WEB サーバー起動サービス設定ファイル(upstart用)
└── xsk # <=============== サーバー起動コマンド
```

XSK を使用する場合、サーバー用コマンドの `xsk`と、クライアントコマンドの `pilot` を主に使用します。

#### XSK起動:
XSK の起動は以下のようにします。HTTPS  を利用する例は、証明書の用意が必要になると思うので、ここでは一旦 80 番ポートで起動する例を試してみてください。

`-p`オプションにポート番号を、`-u`にツイッターユーザー名、`-h`にホスト名を指定します。`-s`オプションを付加した場合に、予め設置しておいた証明書を使用して HTTPS で稼働します。

HTTP: 80 番ポートで稼働する例:

```shell
$ pwd
/path/to/xsk
$ sudo ./xsk -p 80 -u "@ツイッターユーザー名" -h "ホスト名"
```

HTTPS: 443 番ポートで稼働する例:

```shell
$ pwd
/path/to/xsk
$ sudo ./xsk -p 443 -u "@ツイッターユーザー名" -h "ホスト名" -s
```

なお、`GET`以外のメソッドに対する署名の認証用に、下記のような credentials ファイルを用意しておいてください。
`key_id`と`secret`はお好みのそこそこ長いランダム文字列を使用するのをお勧めします。

```shell
$ pwd
/path/to/xsk
$ cat credentials 
key_id: abcd
secret: efghijklmnopqrstuvwxyz
```

これに対して、WEBブラウザと、クライアントコマンド `pilot` を使用して実際にリクエストを作成しアクセスすることになります。

#### PILOT:
`pilot`はクライアントコマンドで、署名済みの HTTP リクエストを生成して XSK にアクセスします。
なお、このコマンドは自身の勉強のためにも作成しています。できるだけ生に近い HTTP リクエストを手作りして、REST を体験したいという思いがあり、このコマンドもその思想のもとに作成されました。
基本的に、生の HTTP リクエストもどきをテキスト形式で作成しておいて、これを標準入力から食べさせることで動作します。

そのため、`pilot`コマンドの使い勝手は良くないですけど（むしろ悪い）、比較的面白い試みなのではないかなと個人的には思っています。

ただ、流石に HTTP リクエスト形式を忠実に再現しすぎるのは使いにくすぎるので、いくらか簡単化して、実際に使用する形式は、以下のようなHTTP リクエストもどきの型として定めました:

```http
METHOD URI    
Key Value
key Value
Key Value    
Host: hostname 
Content-Type: application/hoghoge 
X-Xsk-moge: hogehogehoge 

body (JSON, XML. Binary)
```

クエリは、`METHOD URI` の下に key value 形式で分解して指定します。
これをファイルに保存して第一引数に指定するか、標準入力からパイプを通して流し込みます:

```shell
$ ./pilot RESTful_API_file
$ cat RESTful_API_file | ./pilot
```

ただ、上記のコマンドはボディにバイナリを持たせたい時に問題があります。
そこで、そのような問題に対応するためにもいくつかのオプションがあります。

 * `-f file`: HTTP リクエストのボディ部を別ファイルに指定できます。
 * `-q`: リクエスト後、レスポンスボディのみを受け取りたい場合には、このオプションを指定することでレスポンスヘッダを省略できます。
 * `-v`: curlを使用している場合にはリクエスト状況を詳細表示します。
 * `-s`: HTTPS でリクエストを行います。

多少細かく紹介しましたが、実際にみてみる方が早いと思うので、以下で各メソッドの使い方と動作例を記載していきます。

### GET（ブラウザから）
GET については、ブラウザからのみアクセスすることを想定していますので、ここでもブラウザでの例を取り上げます。XSK はディレクトリ構造をそのまま URI の階層構造に適用しているので、その思想をそのまま視覚に取り入れています。そのため、インデックスページでの各コンテンツへのリンクは、tree 型のリンクとして表示されます。

下段のiframeに選択した記事のタイトルと説明が抜粋されます。
１クリックで説明の表示、ダブルクリックか、「go」と書かれたリンクを押下することで、その記事へと飛びます。

![スクリーンショット 2020-03-01 15.43.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/b9a67e51-63a6-b152-e43b-9dd2fa511049.png)


マークダウンも問題なく表示されています:
パンくずリストも表示するようになっています。

![スクリーンショット 2020-03-01 15.54.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/cec904f0-9ab6-c0e9-4258-1dc9d12e35bc.png)

### PUT
PUT では、新規リソースを作成するか、既存 URI へ上書き更新するのでした。
以下の Markdownファイル(aiu.md)を新規に追加してみましょう。

```markdown
# POSTテスト
これはテストです。
!--

## アイウエオ
かきくけこ
```

`# 見出し1`が記事のタイトルになり、通常一記事に対して一つのみ持つことができます。なお、`!--`マークまでの部分が記事の説明として抜き出されます。ここでは、HTTP リクエストを手作りし続けるのは流石に疲れるので、テンプレを用意しておき、それを `mojihame` コマンドとともに使用することにします[1]。

[1] [Open-usp-Tukubai/mojihame at master · ShellShoccar-jpn/Open-usp-Tukubai · GitHub](https://github.com/ShellShoccar-jpn/Open-usp-Tukubai/blob/master/COMMANDS.SH/mojihame)

実際に`mojihame`コマンドとテンプレートを使用してリクエストを作っていきます:

```shell
$ pwd 
/path/to/xsk
$ cat skeleton/template/PUT 
PUT %1
Host: localhost
$ echo /aiu.md | ./skeleton/mojihame skeleton/template/PUT - 
PUT /aiu.md
Host: localhost
```

上記を `pilot`コマンドに食べさせると、勝手に他のリクエストヘッダを補完し、署名を行なってリクエストを発行します:

```shell
$ echo /aiu.md | ./skeleton/mojihame skeleton/template/PUT - | ./pilot -vf aiu.md 2>&1 | grep -E '^(<|>)' 
> PUT /aiu.md HTTP/1.1
> Host: localhost
> User-Agent: curl/7.64.1
> Accept: */*
> Authorization: XSK4-HMAC-SHA256 Credential=/20200229/localhost/localhost/xsk4_request, SignedHeaders=content-length;content-md5;host;x-xsk-content-sha256;x-xsk-date, Signature=f86cff356542b90f30f161a2adc7a81ece065c061178b95a010c892ac8e7070d
> X-Xsk-Date: 20200229T114555Z
> X-Xsk-Content-Sha256: c42a41d73efd1cf620c04912d24b337d2c5f748abde0950c830a2ea28e4c79c4
> Content-MD5: TJC4NBcOm6Ts9zWoUtoVDw==
> Content-Length: 84
> Content-Type: application/x-www-form-urlencoded
> 
< HTTP/1.1 200 OK
< Date: Sat, 29 Feb 2020 20:45:56 JST
< Expires: Sat, 29 Feb 2020 20:45:56 JST
< Server: exoskeleton
< Location: exo/aiu.md
< Content-Type: ; charset="UTF-8"
< 
```

上記は Authorization ヘッダの存在をわかりやすくするために、`pilot`コマンドに`-v`オプションを渡した（ `curl`に`--verbose`オプションを付加した）結果になっています。主にデバッグ用で、本来は下記のようにレスポンスのみ表示します。上記ではわかりやすさのために、`--verbose` オプションを付けて表示しましたが、以降は付加しません。もちろん、Authorization ヘッダの `Signature=`が正しくなければ、リクエストは 403 エラーで終了します。

```shell
$ echo /aiu.md | ./skeleton/mojihame skeleton/template/PUT - | ./pilot -f aiu.md
HTTP/1.1 200 OK
Date: Sat, 29 Feb 2020 20:47:58 JST
Expires: Sat, 29 Feb 2020 20:47:58 JST
Server: exoskeleton
Location: exo/aiu.md
Content-Type: ; charset="UTF-8"
```

webアクセスしてみると、しっかりと反映されていることがわかります。
200 が返ってきていたので当たりまえですね。

![スクリーンショット 2020-02-28 2.12.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/621d16be-183f-ac19-eb03-e9047a47eae7.png)

ディレクトリに対して PUT すれば、ディレクトリが作成されます。ちなみに、XSKでは URI が `/` で終わっている場合には、これをディレクトリに対するリクエストとして処理します。以下例です。
ボディ部には、ディレクトリの説明をmarkdown形式で記載すると、webアクセス時にディレクトリの説明が表示されます。以下では `mojihame` コマンドは使用しないで手で書いてみました。

```shell
$ cat <<END | ./pilot 
> PUT /testdir/
> Host: localhost
> 
> # ディレクトリ
> これはディレクトリです。
> END
HTTP/1.1 200 OK
Date: Sat, 29 Feb 2020 20:52:47 JST
Expires: Sat, 29 Feb 2020 20:52:47 JST
Server: exoskeleton
Location: exo/testdir/
Content-Type: ; charset="UTF-8"
```

無事にディレクトリが追加されていますね。

![スクリーンショット 2020-02-29 20.53.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/6e4c72e6-947a-abb0-5c0a-120d7cac1a86.png)

### POST
POST は既存の URI へ従属するリソースの新規作成か追記を担います。追記の場合には、全体の上書き更新ではない点に注意してください。既存のファイルの末尾にボディ部を書き加えることになります。例えば、先ほど作成した `/aiu.md` に `さしすせそ`の文を追記してみましょう。なお、`POST` は *MIMEマルチパート*形式で作成しなくてはいけません。ここでは `mime-make`コマンドを使用します[2]。

[2] [misc-tools/mime-make at master · ShellShoccar-jpn/misc-tools · GitHub](https://github.com/ShellShoccar-jpn/misc-tools/blob/master/mime-make)

なお、POST 形式の HTTP リクエストを作成するのは大変なので、`mime-make`をラップして POST 形式の HTTP リクエストを作成してくれる `postmaker`というコマンドも作りました。ここではこれを使用します。
第一引数にファイル名、第二引数に親ノードを指定します。

```shell
$ echo さしすせそ > aiu.md
$ cat aiu.md
さしすせそ
$ ./skeleton/postmaker aiu.md /
POST /
Host: localhost
Content-Type: multipart/form-data

--MimeBoundary-OaRaWP0PjrD5SdLjor1rodr14UUEfCJjK9XT3O90k1Sip38Hx2cOovEqiOIWZ
Content-Disposition: form-data; name="DATA"; filename="aiu.md"
Content-Type: application/octet-stream

さしすせそ

--MimeBoundary-OaRaWP0PjrD5SdLjor1rodr14UUEfCJjK9XT3O90k1Sip38Hx2cOovEqiOIWZ
Content-Disposition: form-data; name="PATH"

/
--MimeBoundary-OaRaWP0PjrD5SdLjor1rodr14UUEfCJjK9XT3O90k1Sip38Hx2cOovEqiOIWZ--
```

上記を`pilot`コマンドに食べさせます:

```shell
$ ./skeleton/postmaker aiu.md / | ./pilot 
HTTP/1.1 200 OK
Date: Sat, 29 Feb 2020 21:33:44 JST
Expires: Sat, 29 Feb 2020 21:33:44 JST
Server: exoskeleton
Location: /aiu.md
Content-Type: ; charset="UTF-8"
```

レスポンスを見ていただけますとわかりますが、`Location`ヘッダにリソースの URI が表示されてますね。
WEBページを見ると以下のようになっていました。こちらもしっかり反映されてます:

![スクリーンショット 2020-02-29 22.07.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/79537ce1-ee87-84dd-acf6-33fe7cfea14c.png)


### PATCH
PATCHメソッドはそのまま内部では`patch`コマンドとして動作します。
bodyにはdiffデータを指定すればいいことになります。ここでは、`aiu.md`から、「かきくけこ」の文字を削除してみることにします。

「かきくけこ」の文字を消したファイルを作成して:

```shell
$ cat aiu2.md 
# POSTテスト
これはテストです。
!--

## アイウエオ
さしすせそ
```

`diff`を取ります:

```shell
$ diff -u aiu.md aiu2.md 
--- aiu.md	2020-02-29 22:13:05.000000000 +0900
+++ aiu2.md	2020-02-29 22:13:22.000000000 +0900
@@ -3,5 +3,4 @@
 !--
 
 ## アイウエオ
-かきくけこ
 さしすせそ
```

リクエストを生成して `pilot`コマンドでアクセスします:

```
$ echo /aiu.md | ./skeleton/mojihame skeleton/template/PATCH - | ./pilot -f aiu-diff.md 
patching file exo/aiu.md
HTTP/1.1 200 OK
Date: Sat, 29 Feb 2020 22:15:11 JST
Expires: Sat, 29 Feb 2020 22:15:11 JST
Server: exoskeleton
Location: exo/aiu.md
Content-Type: ; charset="UTF-8"
```

無事反映されました:

![スクリーンショット 2020-02-29 22.17.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/b8049afb-17ee-9fb1-56ed-4607b36dc23e.png)


### DELETE
DELETE はその名の通りリソースの削除です。
今まで作成した`aiu.md`を削除しましょう。以下のようになります:

```shell
$ cat<<END | ./pilot 
> DELETE /aiu.md
> Host: localhost
> END
HTTP/1.1 204 NoContent
Date: Sun, 01 Mar 2020 00:33:17 JST
Expires: Sun, 01 Mar 2020 00:33:17 JST
Server: exoskeleton
Content-Type: ; charset="UTF-8"
```

削除するとコンテンツがなくなるわけなので、`204`が返却されています。
削除した後にアクセスすると `404`が返ってきます。

```shell
$ curl -include localhost/aiu.md
HTTP/1.1 404 Not Found
Date: Sun, 01 Mar 2020 00:37:17 JST
Expires: Sun, 01 Mar 2020 00:37:17 JST
Server: exoskeleton
Content-Type: ; charset="UTF-8"

<html><body><h1>404 Not Found</h1></body></html>
```

### OPTIONS
許可されているメソッド一覧を返します。
ただ、これは今現在手抜き実装となっており、404か下記の通りの全てのメソッドが載った Allow ヘッダしか返しません。本来はファイルのパーミションなどに応じたメソッドを返すべきでしょう。

```shell
$ cat<<END | ./pilot 
> OPTIONS /
> Host: localhost
> END
HTTP/1.1 200 OK
Date: Sun, 01 Mar 2020 00:43:39 JST
Expires: Sun, 01 Mar 2020 00:43:39 JST
Server: exoskeleton
Allow: DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT
Content-Type: ; charset="UTF-8"
```

### HEAD
ここでは、単にリソースの存在有無を確かめ、ステータスコードでその結果を返します。

```shell
$ cat<<END | ./pilot 
> HEAD /aiu.md
> Host: localhost
> END
HTTP/1.1 404 Not Found
Date: Sun, 01 Mar 2020 00:45:47 JST
Expires: Sun, 01 Mar 2020 00:45:47 JST
Server: exoskeleton
Content-Type: ; charset="UTF-8"
```

```shell
$ cat<<END | ./pilot 
> HEAD /
> Host: localhost
> END
HTTP/1.1 200 OK
Date: Sun, 01 Mar 2020 00:46:16 JST
Expires: Sun, 01 Mar 2020 00:46:16 JST
Server: exoskeleton
Content-Type: ; charset="UTF-8"
```

どんな実装なのか少し覗いてみることにします。

## 実装
以降は大雑把に、XSK のコア機能がどのように実装されているのかみていくことにしましょう。

### xsk
サーバーの本体は `xsk`スクリプトですが、実はこれ自体はかなり単純な作りとなっています。
`socat`のラッパーに過ぎず、リクエストを fork して処理するように書いてあります。

```shell
# === Run as a web server =======================================
if [ -n "${CMD_SOCAT:-}" ]; then                                #
  socat ${LISTEN}:${PORT},pktinfo,reuseaddr,fork${SSL:-}   \
  EXEC:"skeleton/core.sk                                        \
  ${DOPT:-} ${ROPT:-}" 2>/dev/null                              #
else                                                            #
 while :                                                        #
  do                                                            #
    cat "$PIPE"                                         |       #
    ${CMD_NC:-}${CMD_NETCAT:-} -l ${PORT}               |       #
    skeleton/core.sk 1>"$PIPE"                          #       #
    [ $? != 0 ] && break                                #       #
  done                                                          #
fi                                                              #
```

みるからに単純です。実は互換性確保のために、`nc`による無限ループでも起動できるようにしてはいるのですが、まぁダメです。特に HTTPS では機能できません。
オプションの細かい意味は説明しなくてもお分かりいただけると思います。
比較的重要なのが`pktinfo`ですね。IP 情報などアクセス元の情報を取得します。ログ情報として使用する他、将来的には IP に応じたアクセス制限機能を実装したいと思います。

上記の中で他に特に注目していただきたいのが、`EXEC`に指定してある`skeleton/core.sk`です。

### skeleton/core
文字通り、これがこの WEB サーバー兼CMSのコア部分です。
基本的には、`case`文を使用して HTTP メソッドに応じて処理を振り分けます:

```shell
case "${REQUEST_METHOD}" in
  GET     ) # --- GET METHOD ------------------------------------- #
    target=$(chamber -m GET                                        \
            -u "${REQUEST_URI:-N}"                                 \
            -q "${QUERY_STRING:-N}"                                )
    if [ ! -n "${target:-}"  ];
    then
      GET.sk -q "${QUERY_STRING:-N}" "${URL_PATH}" "${target:-}"
    else
      response 500 <<-END
        <html><body><h1>500 Internal server error/h1></body></html>
        END
    fi
    ;;
  PUT     ) # --- PUT METHOD ------------------------------------- #
    target=$(chamber -m PUT -v                                     \
            -u "${REQUEST_URI:-N}"                                 \
            -q "${QUERY_STRING:-N}"                                )
    if [  -n "${target:-}"   ];
    then
      PUT.sk -q "${QUERY_STRING:-N}" "${URL_PATH}" "${target:-}"
    else
      response 403 <<-END
        <html><body><h1>403 Forbidden</h1></body></html>
        END
    fi
...
```

なお、このコードの目玉は、内部で使用している `chamber`コマンドと各メソッドの名前のコマンド(例:`PUT.sk`)と、 `response`コマンドです。

### skeleton/chamber
`chamber`コマンドは、Authorizationヘッダのバリデーションとログ記録を担当しています。
オプションはそれぞれ下記の意味です。

* `-m`: 検証するメソッドの指定
* `-u`: 検証する URI の指定
* `-q`: 検証する クエリの指定
* `-t`: 検証しない URI を正規表現で指定

このコマンドは比較的長めのシェルスクリプトであり、いい感じに抜粋できる処理部分もないので、ここでは実装詳細は省略します。
以下の処理を行います。

1. 標準入力を読み取る
2. ログを記録する
3. リクエストのボディをContent-Lengthヘッダに指定されたバイト数分だけ、一時ファイルに溜め込む
4. Authorizationヘッダ&日時情報以外のリクエストヘッダを使用して、溜め込んだペイロードをもとに署名計算を行う
5. 計算した署名が、Authorizationヘッダの内容と一致するか確認する
6. 計算結果が一致した場合にはリクエストボディを格納した一時ファイルへのパスを返す

標準入力を読み取って、そこから処理が進んでいく様は、これぞ本質という感じがして、個人的には気に入っています。
なお、署名の計算は下記のようになっています。まさに AWS の sig4 ですね。

```shell
# === XSK version 4 Signature 5 Sign Step ==================== #
SSTEP0=$(printf "$MESSAGEDATE_A"                                  | 
	openssl sha256 -hmac "XSK4${XSK_SECRET_KEY}" -binary      |
        od -v -tx1 -An                                            |
       	tr -dc '[a-z0-9]'                                         )
SSTEP1=$(printf "$REGION"                                         |
	openssl sha256 -mac HMAC -macopt hexkey:"$SSTEP0" -binary |
        od -v -tx1 -An                                            |
        tr -dc '[a-z0-9]'                                         )
SSTEP2=$(printf "${SERVICE}"                                      |
	openssl sha256 -mac HMAC -macopt hexkey:"$SSTEP1" -binary |
        od -v -tx1 -An                                            |
        tr -dc '[a-z0-9]'                                         )
SSTEP3=$(printf "xsk4_request"                                    |
	openssl sha256 -mac HMAC -macopt hexkey:"$SSTEP2" -binary |
        od -v -tx1 -An                                            |
        tr -dc '[a-z0-9]'                                         )
SIGNATURE=$(printf "$STRING_TO_SIGN"                              |
	openssl sha256 -mac HMAC -macopt hexkey:"$SSTEP3" -binary |
        od -v -tx1 -An                                            |
        tr -dc '[a-z0-9]'                                         )
```

openssl のオプションについては、 @grethlen さんに教えていただきました。🙇‍♂️

[ぐれさん😉さんのツイート: "実はまさに同じ問題に昔あたったことがありました。私の場合はstdinを抑止するオプションはないと結論づけ、-hexではなく-binaryにして回避しました。もちろんsedでも良いんじゃないかなと思います。$ echo aaa | openssl sha256 -hmac test -binary | od -v -tx1 -An | tr -dc 'a-z0-9'… https://t.co/CkukVr2XOy"](https://twitter.com/grethlen/status/1231962276808273921?s=20)

### skeleton/[METHOD].sk
メソッド名が名前になっているコマンドです。
`chamber`によるバリデーションの結果、payloadを格納する一時ファイルへのPATHが返ってきた場合には、このメソッド名が名前になっている各コマンドで、そのメソッドごとの処理を行います。
例えば、`skeleton/PUT.sk`は以下のようになっています。

```shell
#################################################################
# PUT FILE OR DIRECTORY                                         #
#################################################################
#                                                               #
# === 1 PUT if directory ====================================== #
if echo $URL_PATH | grep '/$' 2>&1 >/dev/null  ;                #
then                                                            #
  DIR_PATH="${URL_PATH%/}"                                      #
  [ -d ${DIR_PATH%/*}      -a \
    -x ${DIR_PATH%/*}    ] && {                                 #
  mkdir -p ${DIR_PATH}                                          #
  cat $UPTARGET > "${DIR_PATH}/.memo.md"                        #
  response -H "Location: ${URL_PATH}" 200 <<-RESPONSE
	RESPONSE
  rm $UPTARGET 2>/dev/null                                      #
  exit 0;                } || {                                 #
  response 403 <<-RESPONSE
	RESPONSE
  rm $UPTARGET 2>/dev/null                                      #
  exit 0;                                                       #
  }                                                             #
# === 2 PUT if file =========================================== #
else                                                            #
  DIR_PATH="${URL_PATH%/*}"                                     #
  [ -d ${DIR_PATH}        -a \
    -x ${DIR_PATH}      ] && {                                  #
  cat $UPTARGET > "${URL_PATH}"                                 #
  response -H "Location: ${URL_PATH}" 200 <<-RESPONSE
	RESPONSE
  rm $UPTARGET 2>/dev/null                                      #
  exit 0;               } || {                                  #
  response 403 <<-RESPONSE
	RESPONSE
  rm $UPTARGET 2>/dev/null                                      #
  exit 0;                                                       #
  }                                                             #
fi                                                              #
#                                                               #
# === 4. Error ================================================ #
response 500 <<-RESPONSE
	RESPONSE
exit 0                                                          #
```

こちらも実態はかなり単純であることがわかります。
ファイルかディレクトリであるかをURIの形式から判定して、ファイルであればファイルを、ディレクトリであればディレクトリを作成します。ちなみに、XSK では、URIの末尾が`/`で終わっている場合には、これをディレクトリとみなすことにしています。

他のコマンドも全く同じような実装になっています。
違うのはリソースに作用する時の内部コマンドだけで、例えば PATCH メソッドには `patch`コマンドを使用します。

### skeleton/response 
クライアントに返す HTTP レスポンスを生成するのは、`response`コマンドの仕事です。
`response`コマンドには以下の特徴とオプションがあります。

* 第一引数にステータスコードを指定する(例: `200`、`403`など)
* レスポンスボディを標準入力から受け取るか、レスポンスを生成する/もとになるファイルを第２引数に指定
* `-H`オプションで追加のレスポンスヘッダを何個でも付与できる(`-H ヘッダ1`、`-H ヘッダ2`)
* `-t`オプションでレスポンスのコンテントタイプを指定できる
* レスポンスの第二引数に指定されたのが`*.sh`のシェルスクリプトであった場合にはこれを実行する
* その際、 `-q`オプションに実行するシェルスクリプトに渡すクエリー(引数)を指定できる。このクエリは HTTP リクエストのクエリを横流しするだけなので、このクエリを解釈するのはシェルスクリプトの仕事である。
* レスポンスの第二引数に指定されたのが`*.md`である場合には、`skeleton/skmd`コマンドを実行して、markdownは HTML に変換する
* レスポンスの第二引数に指定されたのが`*/`で終わるディレクトリの場合には、`skeleton/sktree`を実行して tree 形式のインデックスページを生成する

こちらも以下のような単純な実装になっています。

```shell
cat<<-__RESPONSEHEADER                                          |
	$(printf '%s\n' "$STATUS" | awk '                       #
	BEGIN{                                                  #
	  res[200]="OK";                                        #
	  res[204]="NoContent";                                 #
	  res[400]="Bad Request";                               #
	  res[403]="Forbidden";                                 #
	  res[404]="Not Found";                                 #
	  res[405]="Method Not Allowed";                        #
	  res[500]="Internal Server Error";                     #
	}                                                       #
	{ printf "HTTP/1.1 %s ", $0;                            #
	  print ($0 in res) ? res[$0] : Error;                  #
	}')
	Date: $DATE
	Expires: $DATE
	Server: ${SNAME:-exoskeleton}
	${HEADERS:-}
	${CONTENTTYPE:-Content-Type: text/html}
__RESPONSEHEADER
grep -v '^$'                                                    |
sed '/Content-Type/ s/$/; charset="UTF-8"/'                     #
echo 
if   [ $(($FLAG & 1)) != 0 ]; then                              #
     [ -x "$FILE"          ]  && { $FILE ${QUERY_STRING:-}; }  ||
     { echo "<html><body><h1>403 Forbidden</h1></body></html>"; }
elif [ $(($FLAG & 2)) != 0 ]; then                              #
     [ -x $(which skmd)    ]  && { skmd ${FILE} ;     }  ||
     { echo "<html><body><h1>403 Forbiddon</h1></body></html>"; }
elif [ $(($FLAG & 4)) != 0 ]; then                              #
     [ -x $(which sktree)  ]  && { sktree $FILE ;           }  ||
     { echo "<html><body><h1>403 Forbidden</h1></body></html>"; }
else
     cat $FILE
fi
```

### skeleton/skmd
このコマンドは markdown を HTML に変換するコマンドです。もちろんシェルスクリプトです。

### skeleton/sktree
このコマンドは find コマンドを使用して、tree 形式の表示を作り出します。

### コメント機能
コメント機能は、POST メソッドをしようします。なお、`skeleton/POST.sk` は以下のように `.comment-*`の文字列を含む URI については、認証をスルーするようにしています。このおかげでコメント機能を使えます。
ただし、実際に対象のファイルが存在しない場合には書き込みを行うことはできません。
コメントを記述していくファイルは、必ず `.comments-コメント先のファイル名.md`です。例えば、`aiu.md`にコメントをつけれるようにするためには、同じディレクトリに`.comments-aiu.md`を作成する必要があります。PUT で作成してみてください。

```shell
  POST    ) # --- POST METHOD ------------------------------------ #
    target=$(chamber -m POST -v -t '\.comments-.*\.md$'            \
            -p "${URL_PATH}"                                       \
	    -u "${REQUEST_URI:-N}"                                 \
	    -q "${QUERY_STRING:-N}"                                )
    if [ -n "${target:-}"    ];
    then
      POST.sk -q "${QUERY_STRING:-N}" "${URL_PATH}" "${target:-}"
    else
      response 403 <<-END
	<html><body><h1>403 Forbidden</h1></body></html>
	END
    fi
```

以下のような感じになります。

![スクリーンショット 2020-03-01 3.08.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/b7fdc8fc-177c-f3dd-71c8-70ba6eb4893b.png)


### ランキング表示
簡便なものですが、アクセスログからランキングを表示するコマンドがあります。
xskのトップディレクトリ配下に`.log`ディレクトリがあり、この記録から最もアクセスされている記事をアクセス数とともに表示します。

```shell
$ pwd 
path/to/xsk
$ ./skeleton/ranking .log/*
  17 /aiu.md
   5 /test.md
   4 /posttest.md
   2 /test/test1.md
```

詳細が気になる方は、GitHub を覗いてください。
最後にいくつか発展的なトピックを扱って終わりにしたいと思います。

## シェルスクリプトを実行する
XSK はシェルスクリプトを実行できるのでした。
その例をいくつか扱いたいと思います。

### 記事の説明部分を抜き出してみる
公開ディレクトリ `exo/.attachments/sh/`の下に `stub.sh`というシェルスクリプトが存在します。このシェルスクリプトは、記事タイトルと記事の説明を抜き出すものです。具体的には markdown 中の`# 見出し1`から`!--`までを抜き出します。
なお、抜き出す記事の選択は`path`クエリの値として、WEBサーバーのルートディレクトリからの絶対パスとして指定します。

以下のような感じです。

```http
GET /.attachments/sh/stub.sh?path=/aiu.md
```

このスクリプトを使用して、インデックスページの iframeに記事の切り抜きを表示しています。
もちろん、他にも実行したいスクリプトがあるのなら、それを簡単に実装することもできます。

### grep検索を実装してみよう!
`exo/.attachments/sh/`配下に、以下のような`xgrep.sh`というスクリプトを作成してみてください。
XSK に簡単に簡易検索機能を実装することができます。

```shell
$ cat exo/.attachments/sh/xgrep.sh
#!/bin/sh
LF=$(printf '\\\n_')
LF=${LF%_}
this=$(dirname $0)
CGINAME="$this/../../../skeleton/cgi-name"
NAMEREAD="$this/../../../skeleton/nameread"
QUERY="$(printf "%s" $1 | ${CGINAME} | ${NAMEREAD} q -)"
cd $this/../../../exo 
grep $QUERY ./* | sed 's/$/<br\/>/'
exit 0
```

以下は実行してみた例です

![スクリーンショット 2020-03-01 2.06.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/a02429b9-d3e1-5bba-b813-c7a184a79cc1.png)

`pilot`コマンドでも同じような結果を取得できます。
クエリはkey-value形式に分解しなければいけません。

```shell
$ cat <<END | ./pilot 
> GET /.attachments/sh/xgrep.sh
> q リスト
> Host: localhost
> END
HTTP/1.1 200 OK
Date: Sun, 01 Mar 2020 16:27:09 JST
Expires: Sun, 01 Mar 2020 16:27:09 JST
Server: exoskeleton
Content-Type: text/html; charset="UTF-8"

./test.md:## リスト<br/>
./test.md:リストは１階層ぶんだけ表示できます。  <br/>
./test.md:- リストは入れ子にできません。  <br/>
./test.md:- リストの開始の際には空行で前後をはさみます<br/>
```

簡単ですね。
このようにシェルスクリプトでの拡張性も十分？だと思います。

[注]ここで使用している `cgi-name`と`nameread`のコマンドは例によって秘密結社様のものです。
便利ですね！素晴らしいコマンドをありがとうございますmm

## HTTPS に対応しよう！
XSK は証明書を配置すれば HTTPS に対応できるのでした。
ここでは、 [Certificates for localhost -  Let's Encrypt - Free SSL/TLS Certificates](https://letsencrypt.org/ja/docs/certificates-for-localhost/) を参考にして、HTTPS 対応してみましょう。

```shell
$ cd path/to/xsk/.xsk
$ openssl req -x509 -out fullchain.pem -keyout privkey.pem \
  -newkey rsa:2048 -nodes -sha256 \
  -subj '/CN=localhost' -extensions EXT -config <( \
   printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
$ pwd
path/to/xsk/.xsk
$ ls
credentials	fullchain.pem	privkey.pem
```

以下 macOS での例です:

1: 上記で生成した `fullchain.pem`をキーチェーンに追加します。
キーチェーンアクセスを開いてサイドバーの「システム」にファイルをドロップします。以下のように「localhost」の証明書が追加されます。

![スクリーンショット 2020-03-01 2.48.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/b45acaa8-a50d-cf7b-a082-01f577f5668d.png)

2: 証明書をダブルクリックして、「常に信頼」を設定します。

![スクリーンショット 2020-03-01 2.49.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/d6228774-b624-62ff-d913-90086e6fb7fc.png)


以上で設定は完了です。
HTTPS モードで起動してみましょう。

```shell
$ pwd
/path/to/exoskeleton
$ sudo ./xsk -p 443 -u "@ツイッターユーザー名" -h "ホスト名" -s
```

![スクリーンショット 2020-03-01 2.52.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254122/7a5bd50f-6ace-f187-3163-b07858223a3c.png)


もちろん `pilot`コマンドは `-s`オプションで動作します。

```
$ echo /aiu.md | ./skeleton/mojihame skeleton/template/PUT - | ./pilot -vsf aiu.md 
*   Trying ::1...
* TCP_NODELAY set
* Connection failed
* connect to ::1 port 443 failed: Connection refused
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/cert.pem
  CApath: none
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server did not agree to a protocol
* Server certificate:
*  subject: CN=localhost
*  start date: Feb 29 17:38:47 2020 GMT
*  expire date: Mar 30 17:38:47 2020 GMT
*  subjectAltName: host "localhost" matched cert's "localhost"
*  issuer: CN=localhost
*  SSL certificate verify ok.
> PUT /aiu.md HTTP/1.1
> Host: localhost
> User-Agent: curl/7.64.1
> Accept: */*
> Authorization: XSK4-HMAC-SHA256 Credential=/20200229/localhost/localhost/xsk4_request, SignedHeaders=content-length;content-md5;host;x-xsk-content-sha256;x-xsk-date, Signature=897421a8696ba0ffd73b2401de57d50a0fcd3b8005700294dedc7bca7d135faa
> X-Xsk-Date: 20200229T175659Z
> X-Xsk-Content-Sha256: c42a41d73efd1cf620c04912d24b337d2c5f748abde0950c830a2ea28e4c79c4
> Content-MD5: TJC4NBcOm6Ts9zWoUtoVDw==
> Content-Length: 84
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 84 out of 84 bytes
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< Date: Sun, 01 Mar 2020 02:56:59 JST
Date: Sun, 01 Mar 2020 02:56:59 JST
< Expires: Sun, 01 Mar 2020 02:56:59 JST
Expires: Sun, 01 Mar 2020 02:56:59 JST
< Server: exoskeleton
Server: exoskeleton
< Location: exo/aiu.md
Location: exo/aiu.md
< Content-Type: ; charset="UTF-8"
Content-Type: ; charset="UTF-8"
* no chunk, no close, no size. Assume close to signal end

< 
* TLSv1.2 (IN), TLS alert, close notify (256):
* Closing connection 0
* TLSv1.2 (OUT), TLS alert, close notify (256):
```

## あとがき
いかがでしたでしょうか？
シェルスクリプトでもそこそこ？ほんのちょっとはまともな？ HTTP/HTTPS 対応の CMS 兼 WEB サーバーが作れていると言えませんでしょうか。。(え、言えない？😭) ただ、S3 ライクな HTTP メソッドでの操作とディレクトリ構造そのままの URI の構成は、シンプルで明快であり、お遊びとお勉強にはちょうどいいと思います。

拡張もそこそこしやすいように作ったつもりです。もし興味のある方がいれば、拡張用のモジュールの追加や機能のリクエスト、修正などは大歓迎です。githubのissueやプルリクを投げていただければと思います。

注:もちろん、なんでもシェルスクリプトでやるという考え方には賛成はしているわけではありません。個人の趣味の範囲なら問題ないでしょう。